Compare commits
105 Commits
20ddba4c5f
...
1960e28298
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1960e28298 | ||
|
|
b18b24b085 | ||
|
|
f743b27696 | ||
|
|
80f249f8a2 | ||
|
|
b4aa07577f | ||
|
|
b54a47f333 | ||
|
|
0996074287 | ||
|
|
81aac701f4 | ||
|
|
a81289311f | ||
|
|
b44354315a | ||
|
|
216dcab316 | ||
|
|
c6259e5a14 | ||
|
|
2f7a2305da | ||
|
|
86d86f6ead | ||
|
|
2b6c7bd6a4 | ||
|
|
869f9ce13d | ||
|
|
0bb0f46abc | ||
|
|
2d65fcd91c | ||
|
|
d848f4a03f | ||
|
|
2d13f0b72d | ||
|
|
a6e5c9f0bc | ||
|
|
b5b4781693 | ||
|
|
17591a6ebd | ||
|
|
d078f275f8 | ||
|
|
300eee36e9 | ||
|
|
f40ecb8ca7 | ||
|
|
1813217c16 | ||
|
|
c1eec9057a | ||
|
|
742058403c | ||
|
|
b2a5b69423 | ||
|
|
366a71688d | ||
|
|
0d32d2167b | ||
|
|
9f7e78691b | ||
|
|
206be77a86 | ||
|
|
b117fe825c | ||
|
|
11215bd69f | ||
|
|
f204656f4d | ||
|
|
da6b448598 | ||
|
|
2f4420bf15 | ||
|
|
b50d69d3a5 | ||
|
|
483cc1c1e0 | ||
|
|
6123a64b37 | ||
|
|
9ff6c70faf | ||
|
|
b447f5032d | ||
|
|
51ef460d39 | ||
|
|
ee808a60a2 | ||
|
|
8de78ba7ed | ||
|
|
5334263793 | ||
|
|
c2c3abc6f0 | ||
|
|
6ec6a9c3fc | ||
|
|
d759465cf2 | ||
|
|
349d7f32cd | ||
|
|
30cb4727f6 | ||
|
|
38a21e5e72 | ||
|
|
5cf3cf70e8 | ||
|
|
259e3ebe29 | ||
|
|
c72b692560 | ||
|
|
d767944a83 | ||
|
|
21ee113d21 | ||
|
|
4aa1367b6f | ||
|
|
f1d96ff554 | ||
|
|
9130d6de11 | ||
|
|
d01294ebb4 | ||
|
|
37d89aa602 | ||
|
|
f9a971a546 | ||
|
|
00fbf1a185 | ||
|
|
77ad614258 | ||
|
|
fb1e6829b7 | ||
|
|
bea5a1efd4 | ||
|
|
015c7ce259 | ||
|
|
f394529c8c | ||
|
|
51595b0c7d | ||
|
|
0d036e1bff | ||
|
|
82dc877639 | ||
|
|
645c32e11c | ||
|
|
9f65326449 | ||
|
|
998402ebc3 | ||
|
|
7118b92522 | ||
|
|
833bd85d36 | ||
|
|
57d231cd56 | ||
|
|
6c7e8ae8ad | ||
|
|
b9c29b53d9 | ||
|
|
7d7cf699f8 | ||
|
|
d762c5766f | ||
|
|
7e4a9654b2 | ||
|
|
feee6e7c09 | ||
|
|
83e89455e2 | ||
|
|
7a582f310e | ||
|
|
f1d881b26a | ||
|
|
ca36792be3 | ||
|
|
6098682162 | ||
|
|
fafd7ea183 | ||
|
|
2b35ae0890 | ||
|
|
c1d7cd2441 | ||
|
|
bf51dabcff | ||
|
|
2ce399ff87 | ||
|
|
f991ef762f | ||
|
|
eea596c6ec | ||
|
|
a6a8c6b1a4 | ||
|
|
ce32a9c6b7 | ||
|
|
9d6a5cc3b9 | ||
|
|
7e757ebcd2 | ||
|
|
6d60edaa2a | ||
|
|
7a82f4e189 | ||
|
|
d3488c3bc6 |
14
SVSim.Bootstrap/Data/seeds/home-dialogs.json
Normal file
14
SVSim.Bootstrap/Data/seeds/home-dialogs.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title_text_id": "HomeDialog_0066",
|
||||
"image": "home_dialog_000312",
|
||||
"button_list": [
|
||||
{ "button_text_id": "HomeDialog_0002", "scene": "card_pack", "status": "80032" }
|
||||
],
|
||||
"begin_time": "2026-06-01 02:00:00",
|
||||
"end_time": "2026-07-01 01:59:59",
|
||||
"type": 1,
|
||||
"priority": 0
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
7
SVSim.Bootstrap/Data/seeds/tutorial-presents.json
Normal file
7
SVSim.Bootstrap/Data/seeds/tutorial-presents.json
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{ "present_id": "71478626", "reward_type": 1, "reward_detail_id": 0, "reward_count": 400, "item_type": null, "message": "For completing the tutorial" },
|
||||
{ "present_id": "71478627", "reward_type": 9, "reward_detail_id": 0, "reward_count": 100, "item_type": null, "message": "For completing the tutorial" },
|
||||
{ "present_id": "71478628", "reward_type": 4, "reward_detail_id": 1, "reward_count": 3, "item_type": 1, "message": "For completing the tutorial" },
|
||||
{ "present_id": "71478629", "reward_type": 4, "reward_detail_id": 80001, "reward_count": 40, "item_type": 2, "message": "For completing the tutorial" },
|
||||
{ "present_id": "71478630", "reward_type": 4, "reward_detail_id": 90001, "reward_count": 1, "item_type": 2, "message": "For completing the tutorial" }
|
||||
]
|
||||
@@ -190,5 +190,42 @@
|
||||
"dialog_title": "Dia_BuyCard_005_Title"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"parent_gacha_id": 80032,
|
||||
"base_pack_id": 80001,
|
||||
"gacha_type": 1,
|
||||
"pack_category": 1,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2026-06-01 02:00:00",
|
||||
"complete_date": "2026-07-01 01:59:59",
|
||||
"sleeve_id": 5090001,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 80001,
|
||||
"override_ui_effect_pack_id": 80001,
|
||||
"gacha_detail": "Throwback test pack with a free-pack-of-the-day child.",
|
||||
"is_hide": true,
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"is_enabled": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": "2026-07-01 01:59:59",
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{
|
||||
"gacha_id": 780032,
|
||||
"type_detail": 10,
|
||||
"cost": 1,
|
||||
"card_count": 8,
|
||||
"item_id": null,
|
||||
"is_daily_single": false,
|
||||
"override_increase_gacha_point": 0,
|
||||
"purchase_limit_count": 1,
|
||||
"daily_free_gacha_count": 1,
|
||||
"free_gacha_campaign_id": 49,
|
||||
"campaign_name": "New Season Release Bonus"
|
||||
}
|
||||
],
|
||||
"banners": []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -46,6 +46,41 @@ public class MyPageGlobalsImporter
|
||||
return seed.Count;
|
||||
}
|
||||
|
||||
public async Task<int> ImportHomeDialogsAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<HomeDialogSeed>(Path.Combine(seedDir, "home-dialogs.json"));
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[MyPageGlobalsImporter] No home-dialog seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Clear-and-rewrite — same semantics as banners. Seed file is authoritative.
|
||||
var existing = await context.HomeDialogEntries.ToListAsync();
|
||||
context.HomeDialogEntries.RemoveRange(existing);
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
context.HomeDialogEntries.Add(new HomeDialogEntry
|
||||
{
|
||||
Id = s.Id,
|
||||
TitleTextId = s.TitleTextId,
|
||||
Image = s.Image,
|
||||
ButtonListJson = s.ButtonList.ValueKind == JsonValueKind.Undefined
|
||||
? "[]"
|
||||
: JsonSerializer.Serialize(s.ButtonList),
|
||||
BeginTime = ImporterBase.ParseWireDateTime(s.BeginTime),
|
||||
EndTime = ImporterBase.ParseWireDateTime(s.EndTime),
|
||||
Type = s.Type,
|
||||
Priority = s.Priority,
|
||||
});
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[MyPageGlobalsImporter] HomeDialogs: -{existing.Count}/+{seed.Count}");
|
||||
return seed.Count;
|
||||
}
|
||||
|
||||
public async Task<int> ImportColosseumAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var s = SeedLoader.LoadObject<ColosseumSeed>(Path.Combine(seedDir, "colosseum.json"));
|
||||
|
||||
@@ -70,13 +70,14 @@ public class PackImporter
|
||||
pack.ChildGachas.Add(new PackChildGachaEntry
|
||||
{
|
||||
GachaId = c.GachaId,
|
||||
TypeDetail = c.TypeDetail,
|
||||
TypeDetail = (CardPackType)c.TypeDetail,
|
||||
Cost = c.Cost,
|
||||
CardCount = c.CardCount,
|
||||
ItemId = c.ItemId,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount,
|
||||
DailyFreeGachaCount = c.DailyFreeGachaCount,
|
||||
FreeGachaCampaignId = c.FreeGachaCampaignId,
|
||||
CampaignName = c.CampaignName,
|
||||
});
|
||||
@@ -144,13 +145,14 @@ public class PackImporter
|
||||
pack.ChildGachas.Add(new PackChildGachaEntry
|
||||
{
|
||||
GachaId = c.GachaId,
|
||||
TypeDetail = c.TypeDetail,
|
||||
TypeDetail = (CardPackType)c.TypeDetail,
|
||||
Cost = c.Cost,
|
||||
CardCount = c.CardCount,
|
||||
ItemId = c.ItemId,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount,
|
||||
DailyFreeGachaCount = c.DailyFreeGachaCount,
|
||||
FreeGachaCampaignId = c.FreeGachaCampaignId,
|
||||
CampaignName = c.CampaignName,
|
||||
});
|
||||
|
||||
48
SVSim.Bootstrap/Importers/TutorialPresentsImporter.cs
Normal file
48
SVSim.Bootstrap/Importers/TutorialPresentsImporter.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the tutorial-gift catalogue (<c>tutorial-presents.json</c>) into the
|
||||
/// <c>TutorialPresentEntries</c> table. Clear-and-rewrite — the seed file is authoritative;
|
||||
/// hand-edits to the table are not preserved.
|
||||
///
|
||||
/// Read side: <c>ViewerRepository.RegisterAnonymousViewer</c> reads this table and projects
|
||||
/// each row into a <c>ViewerPresent</c> with Source="tutorial" at signup time.
|
||||
/// </summary>
|
||||
public class TutorialPresentsImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<TutorialPresentSeed>(
|
||||
Path.Combine(seedDir, "tutorial-presents.json"));
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[TutorialPresentsImporter] No tutorial-present seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existing = await context.TutorialPresentEntries.ToListAsync();
|
||||
context.TutorialPresentEntries.RemoveRange(existing);
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
context.TutorialPresentEntries.Add(new TutorialPresentEntry
|
||||
{
|
||||
PresentId = s.PresentId,
|
||||
RewardType = s.RewardType,
|
||||
RewardDetailId = s.RewardDetailId,
|
||||
RewardCount = s.RewardCount,
|
||||
ItemType = s.ItemType,
|
||||
Message = s.Message,
|
||||
});
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[TutorialPresentsImporter] TutorialPresentEntries: -{existing.Count}/+{seed.Count}");
|
||||
return seed.Count;
|
||||
}
|
||||
}
|
||||
16
SVSim.Bootstrap/Models/Seed/HomeDialogSeed.cs
Normal file
16
SVSim.Bootstrap/Models/Seed/HomeDialogSeed.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class HomeDialogSeed
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; set; }
|
||||
[JsonPropertyName("title_text_id")] public string TitleTextId { get; set; } = "";
|
||||
[JsonPropertyName("image")] public string Image { get; set; } = "";
|
||||
[JsonPropertyName("button_list")] public JsonElement ButtonList { get; set; }
|
||||
[JsonPropertyName("begin_time")] public string BeginTime { get; set; } = "";
|
||||
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("type")] public int? Type { get; set; }
|
||||
[JsonPropertyName("priority")] public int Priority { get; set; }
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public sealed class PackChildGachaSeed
|
||||
[JsonPropertyName("is_daily_single")] public bool IsDailySingle { get; set; }
|
||||
[JsonPropertyName("override_increase_gacha_point")] public int OverrideIncreaseGachaPoint { get; set; }
|
||||
[JsonPropertyName("purchase_limit_count")] public int PurchaseLimitCount { get; set; }
|
||||
[JsonPropertyName("daily_free_gacha_count")] public int DailyFreeGachaCount { get; set; }
|
||||
[JsonPropertyName("free_gacha_campaign_id")] public int? FreeGachaCampaignId { get; set; }
|
||||
[JsonPropertyName("campaign_name")] public string? CampaignName { get; set; }
|
||||
}
|
||||
|
||||
13
SVSim.Bootstrap/Models/Seed/TutorialPresentSeed.cs
Normal file
13
SVSim.Bootstrap/Models/Seed/TutorialPresentSeed.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class TutorialPresentSeed
|
||||
{
|
||||
[JsonPropertyName("present_id")] public string PresentId { get; set; } = "";
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
|
||||
[JsonPropertyName("reward_count")] public long RewardCount { get; set; }
|
||||
[JsonPropertyName("item_type")] public int? ItemType { get; set; }
|
||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||
}
|
||||
@@ -115,6 +115,9 @@ public static class Program
|
||||
await mypage.ImportSealedAsync(context, opts.SeedDir);
|
||||
await mypage.ImportMasterPointRankingPeriodAsync(context, opts.SeedDir);
|
||||
await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir);
|
||||
await mypage.ImportHomeDialogsAsync(context, opts.SeedDir);
|
||||
|
||||
await new TutorialPresentsImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PackImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
24
SVSim.Database/Enums/CardPackType.cs
Normal file
24
SVSim.Database/Enums/CardPackType.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <c>GachaUI.CardPackType</c> in the decompiled client
|
||||
/// (<c>Shadowverse_Code/GachaUI.cs</c> line 11). Wire value = (int)enum, carried on
|
||||
/// /pack/info as <c>child_gacha_info[].type_detail</c>.
|
||||
/// </summary>
|
||||
public enum CardPackType
|
||||
{
|
||||
None = 0,
|
||||
Crystal = 1,
|
||||
CrystalMulti = 2,
|
||||
Daily = 3,
|
||||
Ticket = 4,
|
||||
TicketMulti = 5,
|
||||
Rupy = 6,
|
||||
RupyMulti = 7,
|
||||
CrystalSpecial = 8,
|
||||
CrystalSelectSkin = 9,
|
||||
FreePacks = 10,
|
||||
FreePackWithSkin = 11,
|
||||
RotationStarterPack = 12,
|
||||
CrystalAcquireSkinCardPack = 13,
|
||||
}
|
||||
4143
SVSim.Database/Migrations/20260608225037_AddHomeDialogEntries.Designer.cs
generated
Normal file
4143
SVSim.Database/Migrations/20260608225037_AddHomeDialogEntries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHomeDialogEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HomeDialogEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
TitleTextId = table.Column<string>(type: "text", nullable: false),
|
||||
Image = table.Column<string>(type: "text", nullable: false),
|
||||
ButtonListJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
BeginTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Type = table.Column<int>(type: "integer", nullable: true),
|
||||
Priority = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HomeDialogEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HomeDialogEntries_BeginTime_EndTime",
|
||||
table: "HomeDialogEntries",
|
||||
columns: new[] { "BeginTime", "EndTime" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_HomeDialogEntries_BeginTime_EndTime",
|
||||
table: "HomeDialogEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "HomeDialogEntries");
|
||||
}
|
||||
}
|
||||
}
|
||||
4190
SVSim.Database/Migrations/20260609004113_AddViewerPresents.Designer.cs
generated
Normal file
4190
SVSim.Database/Migrations/20260609004113_AddViewerPresents.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerPresents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerClaimedTutorialGifts");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerPresents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Status = table.Column<byte>(type: "smallint", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardCount = table.Column<long>(type: "bigint", nullable: false),
|
||||
ConditionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
PresentLimitType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardLimitTime = table.Column<long>(type: "bigint", nullable: false),
|
||||
ItemType = table.Column<int>(type: "integer", nullable: true),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
Source = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerPresents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerPresents_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerPresents_ViewerId_PresentId",
|
||||
table: "ViewerPresents",
|
||||
columns: new[] { "ViewerId", "PresentId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerPresents_ViewerId_Status_CreatedAt",
|
||||
table: "ViewerPresents",
|
||||
columns: new[] { "ViewerId", "Status", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerPresents");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerClaimedTutorialGifts",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerClaimedTutorialGifts", x => new { x.ViewerId, x.PresentId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerClaimedTutorialGifts_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
4193
SVSim.Database/Migrations/20260609012522_AddPackChildGachaDailyFreeGachaCount.Designer.cs
generated
Normal file
4193
SVSim.Database/Migrations/20260609012522_AddPackChildGachaDailyFreeGachaCount.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPackChildGachaDailyFreeGachaCount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DailyFreeGachaCount",
|
||||
table: "PackChildGachaEntry",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DailyFreeGachaCount",
|
||||
table: "PackChildGachaEntry");
|
||||
}
|
||||
}
|
||||
}
|
||||
4217
SVSim.Database/Migrations/20260609013234_AddViewerFreePackClaims.Designer.cs
generated
Normal file
4217
SVSim.Database/Migrations/20260609013234_AddViewerFreePackClaims.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerFreePackClaims : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerFreePackClaim",
|
||||
columns: table => new
|
||||
{
|
||||
FreeGachaCampaignId = table.Column<int>(type: "integer", nullable: false),
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ClaimCount = table.Column<int>(type: "integer", nullable: false),
|
||||
LastClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerFreePackClaim", x => new { x.ViewerId, x.FreeGachaCampaignId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerFreePackClaim_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerFreePackClaim");
|
||||
}
|
||||
}
|
||||
}
|
||||
4244
SVSim.Database/Migrations/20260609132350_AddTutorialPresentEntries.Designer.cs
generated
Normal file
4244
SVSim.Database/Migrations/20260609132350_AddTutorialPresentEntries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTutorialPresentEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TutorialPresentEntries",
|
||||
columns: table => new
|
||||
{
|
||||
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardCount = table.Column<long>(type: "bigint", nullable: false),
|
||||
ItemType = table.Column<int>(type: "integer", nullable: true),
|
||||
Message = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TutorialPresentEntries", x => x.PresentId);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TutorialPresentEntries");
|
||||
}
|
||||
}
|
||||
}
|
||||
4291
SVSim.Database/Migrations/20260609182346_AddViewerAcquireHistory.Designer.cs
generated
Normal file
4291
SVSim.Database/Migrations/20260609182346_AddViewerAcquireHistory.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerAcquireHistory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerAcquireHistory",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardCount = table.Column<int>(type: "integer", nullable: false),
|
||||
AcquireType = table.Column<int>(type: "integer", nullable: false),
|
||||
Message = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
AcquireTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerAcquireHistory", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerAcquireHistory_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerAcquireHistory_ViewerId_AcquireTime_Id",
|
||||
table: "ViewerAcquireHistory",
|
||||
columns: new[] { "ViewerId", "AcquireTime", "Id" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerAcquireHistory");
|
||||
}
|
||||
}
|
||||
}
|
||||
4318
SVSim.Database/Migrations/20260609203145_AddViewerMyPageBgSelection.Designer.cs
generated
Normal file
4318
SVSim.Database/Migrations/20260609203145_AddViewerMyPageBgSelection.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerMyPageBgSelection : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MyPageBgId",
|
||||
table: "Viewers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MyPageBgSelectType",
|
||||
table: "Viewers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerMyPageBgRotation",
|
||||
columns: table => new
|
||||
{
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BgId = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerMyPageBgRotation", x => new { x.ViewerId, x.Slot });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerMyPageBgRotation_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerMyPageBgRotation");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MyPageBgId",
|
||||
table: "Viewers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MyPageBgSelectType",
|
||||
table: "Viewers");
|
||||
}
|
||||
}
|
||||
}
|
||||
4321
SVSim.Database/Migrations/20260609212630_AddViewerClassDataIsRandomLeaderSkin.Designer.cs
generated
Normal file
4321
SVSim.Database/Migrations/20260609212630_AddViewerClassDataIsRandomLeaderSkin.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerClassDataIsRandomLeaderSkin : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsRandomLeaderSkin",
|
||||
table: "ViewerClassData",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsRandomLeaderSkin",
|
||||
table: "ViewerClassData");
|
||||
}
|
||||
}
|
||||
}
|
||||
4445
SVSim.Database/Migrations/20260609224437_AddSerialCodeTables.Designer.cs
generated
Normal file
4445
SVSim.Database/Migrations/20260609224437_AddSerialCodeTables.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
114
SVSim.Database/Migrations/20260609224437_AddSerialCodeTables.cs
Normal file
114
SVSim.Database/Migrations/20260609224437_AddSerialCodeTables.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSerialCodeTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SerialCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Message = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
StartAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
EndAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SerialCodes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SerialCodeRewards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SerialCodeId = table.Column<int>(type: "integer", nullable: false),
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardCount = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SerialCodeRewards", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SerialCodeRewards_SerialCodes_SerialCodeId",
|
||||
column: x => x.SerialCodeId,
|
||||
principalTable: "SerialCodes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerSerialCodeRedemptions",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
SerialCodeId = table.Column<int>(type: "integer", nullable: false),
|
||||
RedeemedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerSerialCodeRedemptions", x => new { x.ViewerId, x.SerialCodeId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerSerialCodeRedemptions_SerialCodes_SerialCodeId",
|
||||
column: x => x.SerialCodeId,
|
||||
principalTable: "SerialCodes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerSerialCodeRedemptions_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SerialCodeRewards_SerialCodeId_Slot",
|
||||
table: "SerialCodeRewards",
|
||||
columns: new[] { "SerialCodeId", "Slot" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SerialCodes_Code",
|
||||
table: "SerialCodes",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerSerialCodeRedemptions_SerialCodeId",
|
||||
table: "ViewerSerialCodeRedemptions",
|
||||
column: "SerialCodeId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SerialCodeRewards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerSerialCodeRedemptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SerialCodes");
|
||||
}
|
||||
}
|
||||
}
|
||||
4564
SVSim.Database/Migrations/20260610014212_AddFriendSystemTables.Designer.cs
generated
Normal file
4564
SVSim.Database/Migrations/20260610014212_AddFriendSystemTables.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFriendSystemTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerFriendApplies",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
FromViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ToViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
MissionType = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerFriendApplies", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerFriendApplies_Viewers_FromViewerId",
|
||||
column: x => x.FromViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerFriendApplies_Viewers_ToViewerId",
|
||||
column: x => x.ToViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerFriends",
|
||||
columns: table => new
|
||||
{
|
||||
OwnerViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
FriendViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerFriends", x => new { x.OwnerViewerId, x.FriendViewerId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerFriends_Viewers_FriendViewerId",
|
||||
column: x => x.FriendViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerFriends_Viewers_OwnerViewerId",
|
||||
column: x => x.OwnerViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerPlayedTogethers",
|
||||
columns: table => new
|
||||
{
|
||||
OwnerViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
OpponentViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PlayedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
PlayedMode = table.Column<int>(type: "integer", nullable: false),
|
||||
BattleType = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckFormat = table.Column<int>(type: "integer", nullable: false),
|
||||
TwoPickType = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerPlayedTogethers", x => new { x.OwnerViewerId, x.OpponentViewerId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerPlayedTogethers_Viewers_OwnerViewerId",
|
||||
column: x => x.OwnerViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerFriendApplies_FromViewerId_ToViewerId",
|
||||
table: "ViewerFriendApplies",
|
||||
columns: new[] { "FromViewerId", "ToViewerId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerFriendApplies_ToViewerId",
|
||||
table: "ViewerFriendApplies",
|
||||
column: "ToViewerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerFriends_FriendViewerId",
|
||||
table: "ViewerFriends",
|
||||
column: "FriendViewerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerFriends_OwnerViewerId_CreatedAt",
|
||||
table: "ViewerFriends",
|
||||
columns: new[] { "OwnerViewerId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerPlayedTogethers_OwnerViewerId_PlayedAt",
|
||||
table: "ViewerPlayedTogethers",
|
||||
columns: new[] { "OwnerViewerId", "PlayedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerFriendApplies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerFriends");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerPlayedTogethers");
|
||||
}
|
||||
}
|
||||
}
|
||||
4641
SVSim.Database/Migrations/20260610113113_AddViewerBattleHistory.Designer.cs
generated
Normal file
4641
SVSim.Database/Migrations/20260610113113_AddViewerBattleHistory.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerBattleHistory : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerBattleHistories",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BattleId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BattleType = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckFormat = table.Column<int>(type: "integer", nullable: false),
|
||||
TwoPickType = table.Column<int>(type: "integer", nullable: false),
|
||||
IsLimitTurn = table.Column<int>(type: "integer", nullable: false),
|
||||
SelfClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
SelfSubClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
SelfCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
SelfRotationId = table.Column<string>(type: "text", nullable: false),
|
||||
OpponentClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
OpponentSubClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
OpponentCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
OpponentName = table.Column<string>(type: "text", nullable: false),
|
||||
OpponentCountryCode = table.Column<string>(type: "text", nullable: false),
|
||||
OpponentEmblemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
OpponentDegreeId = table.Column<long>(type: "bigint", nullable: false),
|
||||
OpponentRotationId = table.Column<string>(type: "text", nullable: false),
|
||||
IsWin = table.Column<bool>(type: "boolean", nullable: false),
|
||||
BattleStartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreateTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerBattleHistories", x => new { x.ViewerId, x.BattleId });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerBattleHistories_ViewerId_CreateTime",
|
||||
table: "ViewerBattleHistories",
|
||||
columns: new[] { "ViewerId", "CreateTime" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerBattleHistories");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1176,6 +1176,46 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("GameConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.HomeDialogEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("BeginTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ButtonListJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("EndTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Image")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TitleTextId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("HomeDialogEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2168,6 +2208,83 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("SealedSeasons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.SerialCodeEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("EndAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTime?>("StartAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SerialCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.SerialCodeRewardEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("RewardCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SerialCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerialCodeId", "Slot");
|
||||
|
||||
b.ToTable("SerialCodeRewards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2490,6 +2607,33 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("StoryDecks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.TutorialPresentEntry", b =>
|
||||
{
|
||||
b.Property<string>("PresentId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int?>("ItemType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RewardCount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("PresentId");
|
||||
|
||||
b.ToTable("TutorialPresentEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2533,6 +2677,12 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<DateTime>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MyPageBgId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MyPageBgSelectType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("ShortUdid")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
@@ -2578,6 +2728,44 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerAchievements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerAcquireHistoryEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("AcquireTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("AcquireType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("RewardCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ViewerId", "AcquireTime", "Id");
|
||||
|
||||
b.ToTable("ViewerAcquireHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerArenaTwoPickRun", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2655,6 +2843,83 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerArenaTwoPickRuns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerBattleHistory", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("BattleId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("BattleStartTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("BattleType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DeckFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("IsLimitTurn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsWin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("OpponentCharaId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("OpponentClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("OpponentCountryCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("OpponentDegreeId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("OpponentEmblemId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("OpponentName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OpponentRotationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("OpponentSubClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SelfCharaId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SelfClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SelfRotationId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SelfSubClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TwoPickType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("ViewerId", "BattleId");
|
||||
|
||||
b.HasIndex("ViewerId", "CreateTime")
|
||||
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
|
||||
|
||||
b.ToTable("ViewerBattleHistories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2734,23 +2999,6 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerBattlePassProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PresentId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("ClaimedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ViewerId", "PresentId");
|
||||
|
||||
b.ToTable("ViewerClaimedTutorialGifts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
@@ -2772,6 +3020,56 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerEventCounters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerFriend", b =>
|
||||
{
|
||||
b.Property<long>("OwnerViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("FriendViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("OwnerViewerId", "FriendViewerId");
|
||||
|
||||
b.HasIndex("FriendViewerId");
|
||||
|
||||
b.HasIndex("OwnerViewerId", "CreatedAt");
|
||||
|
||||
b.ToTable("ViewerFriends");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerFriendApply", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("FromViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("MissionType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("ToViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ToViewerId");
|
||||
|
||||
b.HasIndex("FromViewerId", "ToViewerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ViewerFriendApplies");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerLeaderSkinSetClaim", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
@@ -2832,6 +3130,100 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerMissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerPlayedTogether", b =>
|
||||
{
|
||||
b.Property<long>("OwnerViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("OpponentViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("BattleType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("DeckFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("PlayedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("PlayedMode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TwoPickType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("OwnerViewerId", "OpponentViewerId");
|
||||
|
||||
b.HasIndex("OwnerViewerId", "PlayedAt");
|
||||
|
||||
b.ToTable("ViewerPlayedTogethers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerPresent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("ClaimedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConditionNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("ItemType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PresentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("PresentLimitType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("RewardCount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("RewardLimitTime")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<byte>("Status")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ViewerId", "PresentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ViewerId", "Status", "CreatedAt");
|
||||
|
||||
b.ToTable("ViewerPresents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
@@ -2851,6 +3243,24 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerPuzzleClears");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerSerialCodeRedemption", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("SerialCodeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ViewerId", "SerialCodeId");
|
||||
|
||||
b.HasIndex("SerialCodeId");
|
||||
|
||||
b.ToTable("ViewerSerialCodeRedemptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerSpotCardExchange", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
@@ -3354,6 +3764,9 @@ namespace SVSim.Database.Migrations
|
||||
b1.Property<int>("Cost")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("DailyFreeGachaCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int?>("FreeGachaCampaignId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -3420,6 +3833,15 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.SerialCodeRewardEntry", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.SerialCodeEntry", null)
|
||||
.WithMany("Rewards")
|
||||
.HasForeignKey("SerialCodeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
|
||||
@@ -3568,6 +3990,25 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
|
||||
{
|
||||
b.OwnsMany("SVSim.Database.Models.MyPageBgRotationEntry", "MyPageBgRotation", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BgId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("ViewerId", "Slot");
|
||||
|
||||
b1.ToTable("ViewerMyPageBgRotation", (string)null);
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
@@ -3724,6 +4165,9 @@ namespace SVSim.Database.Migrations
|
||||
b1.Property<int>("Exp")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsRandomLeaderSkin")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<int>("LeaderSkinId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -3803,6 +4247,28 @@ namespace SVSim.Database.Migrations
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerFreePackClaim", "FreePackClaims", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("FreeGachaCampaignId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("ClaimCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<DateTime>("LastClaimedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b1.HasKey("ViewerId", "FreeGachaCampaignId");
|
||||
|
||||
b1.ToTable("ViewerFreePackClaim");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointBalance", "GachaPointBalances", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
@@ -3979,6 +4445,8 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("Currency")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("FreePackClaims");
|
||||
|
||||
b.Navigation("GachaPointBalances");
|
||||
|
||||
b.Navigation("GachaPointReceived");
|
||||
@@ -3991,6 +4459,8 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("MissionData")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MyPageBgRotation");
|
||||
|
||||
b.Navigation("PackOpenCounts");
|
||||
|
||||
b.Navigation("SocialAccountConnections");
|
||||
@@ -4005,15 +4475,13 @@ namespace SVSim.Database.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerAcquireHistoryEntry", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", "Viewer")
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Viewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
|
||||
@@ -4025,6 +4493,36 @@ namespace SVSim.Database.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerFriend", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FriendViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerFriendApply", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FromViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ToViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerMission", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
@@ -4034,6 +4532,41 @@ namespace SVSim.Database.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerPlayedTogether", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OwnerViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerPresent", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.Viewer", "Viewer")
|
||||
.WithMany()
|
||||
.HasForeignKey("ViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Viewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerSerialCodeRedemption", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.SerialCodeEntry", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialCodeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SVSim.Database.Models.Viewer", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ViewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SleeveEntryViewer", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.SleeveEntry", null)
|
||||
@@ -4074,6 +4607,11 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("Puzzles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.SerialCodeEntry", b =>
|
||||
{
|
||||
b.Navigation("Rewards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
31
SVSim.Database/Models/HomeDialogEntry.cs
Normal file
31
SVSim.Database/Models/HomeDialogEntry.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One mypage home-dialog popup from /mypage/index data.home_dialog_list. Id is authored in
|
||||
/// the seed file (no stable wire ID; see banners.json for the same pattern). The dialog fires
|
||||
/// once per viewer per server-process lifetime — see IHomeDialogSessionTracker.
|
||||
/// </summary>
|
||||
public class HomeDialogEntry : BaseEntity<int>
|
||||
{
|
||||
public string TitleTextId { get; set; } = string.Empty;
|
||||
public string Image { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>jsonb — List<HomeDialogButtonSeed> serialized verbatim. Deserialized in
|
||||
/// MyPageController via JsonbReadOptions.</summary>
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string ButtonListJson { get; set; } = "[]";
|
||||
|
||||
public DateTime BeginTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
|
||||
/// <summary>Wire "type" — client parser ignores it but prod sends "1". Nullable so we
|
||||
/// omit when unset; serialized as a string per <c>HomeDialog.Type</c> on the DTO.</summary>
|
||||
public int? Type { get; set; }
|
||||
|
||||
/// <summary>Tiebreaker when multiple entries are active. Higher wins; ID asc breaks
|
||||
/// further ties. Each /mypage/index call emits the highest-priority unfired entry.</summary>
|
||||
public int Priority { get; set; }
|
||||
}
|
||||
16
SVSim.Database/Models/MyPageBgRotationEntry.cs
Normal file
16
SVSim.Database/Models/MyPageBgRotationEntry.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (viewer, slot) in the viewer's saved MyPage BG rotation pool. The client posts
|
||||
/// the full pool on every <c>/user_mypage/update</c> regardless of mode, so the server overwrites
|
||||
/// it atomically each time. Slot is the 0-based position; order is preserved for the
|
||||
/// <c>/mypage/index</c> echo.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class MyPageBgRotationEntry
|
||||
{
|
||||
public int Slot { get; set; }
|
||||
public int BgId { get; set; }
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One sub-option inside a pack (single-open / 10-open / ticket / daily-free).
|
||||
/// Wire shape: one entry of <c>child_gacha_info</c> in /pack/info. Owned by PackConfigEntry.
|
||||
/// <c>TypeDetail</c> corresponds to <c>GachaUI.CardPackType</c>:
|
||||
/// 1=CRYSTAL, 2=CRYSTAL_MULTI, 3=DAILY, 4=TICKET, 5=TICKET_MULTI, 6=RUPY, 7=RUPY_MULTI,
|
||||
/// 8=CRYSTAL_SPECIAL, 9=CRYSTAL_SELECT_SKIN, 10=FREE_PACKS, 11=FREE_PACK_WITH_SKIN,
|
||||
/// 12=ROTATION_STARTER_PACK, 13=CRYSTAL_ACQUIRE_SKIN_CARD_PACK.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class PackChildGachaEntry
|
||||
{
|
||||
public int GachaId { get; set; }
|
||||
public int TypeDetail { get; set; }
|
||||
public CardPackType TypeDetail { get; set; }
|
||||
public int Cost { get; set; }
|
||||
public int CardCount { get; set; }
|
||||
public long? ItemId { get; set; }
|
||||
public bool IsDailySingle { get; set; }
|
||||
public int OverrideIncreaseGachaPoint { get; set; }
|
||||
public int PurchaseLimitCount { get; set; }
|
||||
public int DailyFreeGachaCount { get; set; }
|
||||
public int? FreeGachaCampaignId { get; set; }
|
||||
public string? CampaignName { get; set; }
|
||||
}
|
||||
|
||||
27
SVSim.Database/Models/SerialCodeEntry.cs
Normal file
27
SVSim.Database/Models/SerialCodeEntry.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level entity for a promotional serial code. Admin inserts these directly via SQL;
|
||||
/// there is no JSON seed or admin endpoint. Case-sensitive match on <see cref="Code"/>.
|
||||
/// </summary>
|
||||
public class SerialCodeEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>User-typed code. Case-sensitive; unique index enforces no duplicates.</summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Player-facing mail body, copied onto every <c>ViewerPresent</c> created at redemption.</summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>When the code becomes valid. NULL = always valid from creation.</summary>
|
||||
public DateTime? StartAt { get; set; }
|
||||
|
||||
/// <summary>When the code expires. NULL = never expires.</summary>
|
||||
public DateTime? EndAt { get; set; }
|
||||
|
||||
/// <summary>Admin kill-switch. False = treat as if it doesn't exist.</summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
public List<SerialCodeRewardEntry> Rewards { get; set; } = new List<SerialCodeRewardEntry>();
|
||||
}
|
||||
24
SVSim.Database/Models/SerialCodeRewardEntry.cs
Normal file
24
SVSim.Database/Models/SerialCodeRewardEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One reward slot belonging to a <see cref="SerialCodeEntry"/>. On redemption each row
|
||||
/// becomes one <see cref="ViewerPresent"/> in the player's gift inbox.
|
||||
/// </summary>
|
||||
public class SerialCodeRewardEntry : BaseEntity<int>
|
||||
{
|
||||
public int SerialCodeId { get; set; }
|
||||
|
||||
/// <summary>0-based ordering within the code's rewards.</summary>
|
||||
public int Slot { get; set; }
|
||||
|
||||
/// <summary>UserGoodsType cast to int (matches the wire convention used elsewhere).</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Detail id for the goods. 0 for wallet currencies.</summary>
|
||||
public long RewardDetailId { get; set; }
|
||||
|
||||
/// <summary>Positive integer count.</summary>
|
||||
public int RewardCount { get; set; }
|
||||
}
|
||||
18
SVSim.Database/Models/TutorialPresentEntry.cs
Normal file
18
SVSim.Database/Models/TutorialPresentEntry.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row in the tutorial-gift catalogue every fresh viewer is given at signup. Authored in
|
||||
/// <c>SVSim.Bootstrap/Data/seeds/tutorial-presents.json</c>; <see cref="PresentId"/> is the
|
||||
/// wire-stable identifier and serves as the primary key. <c>ViewerRepository.RegisterAnonymousViewer</c>
|
||||
/// reads this table and projects each row into a <see cref="ViewerPresent"/> with Source="tutorial".
|
||||
/// </summary>
|
||||
public class TutorialPresentEntry
|
||||
{
|
||||
public string PresentId { get; set; } = string.Empty;
|
||||
|
||||
public int RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public long RewardCount { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -33,6 +33,12 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
public DateTime LastLogin { get; set; }
|
||||
|
||||
/// <summary>BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Default 0 = follow equipped deck's leader skin.</summary>
|
||||
public int MyPageBgSelectType { get; set; }
|
||||
|
||||
/// <summary>The single chosen MyPageBG cosmetic id, used when SelectType=CustomBG. 0 = none.</summary>
|
||||
public int MyPageBgId { get; set; }
|
||||
|
||||
#region Owned
|
||||
|
||||
public ViewerInfo Info { get; set; } = new ViewerInfo();
|
||||
@@ -65,6 +71,10 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
|
||||
|
||||
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
|
||||
|
||||
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
|
||||
|
||||
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
|
||||
|
||||
public List<ViewerGachaPointReceived> GachaPointReceived { get; set; } = new List<ViewerGachaPointReceived>();
|
||||
|
||||
30
SVSim.Database/Models/ViewerAcquireHistoryEntry.cs
Normal file
30
SVSim.Database/Models/ViewerAcquireHistoryEntry.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per grant emitted by <c>InventoryTransaction.CommitAsync</c>. Rendered as the
|
||||
/// <c>histories[]</c> array on <c>POST /item_acquire_history/info</c>. Capped at 300 rows
|
||||
/// per viewer; oldest pruned on commit.
|
||||
/// </summary>
|
||||
public sealed class ViewerAcquireHistoryEntry
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ViewerId { get; set; }
|
||||
|
||||
/// <summary>UserGoodsType cast to int; matches the wire <c>reward_type</c>.</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Detail id for the goods; 0 for wallet currencies.</summary>
|
||||
public long RewardDetailId { get; set; }
|
||||
|
||||
/// <summary>Delta granted in this row — NOT a post-state total.</summary>
|
||||
public int RewardCount { get; set; }
|
||||
|
||||
/// <summary>GrantSource cast to int; matches the wire <c>acquire_type</c>.</summary>
|
||||
public int AcquireType { get; set; }
|
||||
|
||||
/// <summary>Pre-localized text the client renders verbatim. Capped at 64 chars.</summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Server UTC at commit time. Stamped once per <c>CommitAsync</c>, identical across all rows in that commit.</summary>
|
||||
public DateTime AcquireTime { get; set; }
|
||||
}
|
||||
39
SVSim.Database/Models/ViewerBattleHistory.cs
Normal file
39
SVSim.Database/Models/ViewerBattleHistory.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per recent battle the viewer participated in, surfaced by /replay/info.
|
||||
/// Composite PK on (ViewerId, BattleId). Retention: 50 rows per viewer, oldest
|
||||
/// evicted on insert (see <see cref="Services.Replay.BattleHistoryWriter"/>).
|
||||
///
|
||||
/// The battle payload itself is NOT stored here — the client uses its local
|
||||
/// <c>NewReplay/<battle_id>/</c> cache for playback. See
|
||||
/// <c>docs/superpowers/specs/2026-06-10-replay-info-design.md</c>.
|
||||
/// </summary>
|
||||
public class ViewerBattleHistory
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public long BattleId { get; set; }
|
||||
|
||||
public int BattleType { get; set; }
|
||||
public int DeckFormat { get; set; }
|
||||
public int TwoPickType { get; set; }
|
||||
public int IsLimitTurn { get; set; }
|
||||
|
||||
public int SelfClassId { get; set; }
|
||||
public int SelfSubClassId { get; set; }
|
||||
public int SelfCharaId { get; set; }
|
||||
public string SelfRotationId { get; set; } = "0";
|
||||
|
||||
public int OpponentClassId { get; set; }
|
||||
public int OpponentSubClassId { get; set; }
|
||||
public int OpponentCharaId { get; set; }
|
||||
public string OpponentName { get; set; } = "";
|
||||
public string OpponentCountryCode { get; set; } = "";
|
||||
public long OpponentEmblemId { get; set; }
|
||||
public long OpponentDegreeId { get; set; }
|
||||
public string OpponentRotationId { get; set; } = "0";
|
||||
|
||||
public bool IsWin { get; set; }
|
||||
public DateTime BattleStartTime { get; set; }
|
||||
public DateTime CreateTime { get; set; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Records that a viewer has claimed a specific tutorial gift present_id. Composite key
|
||||
/// (ViewerId, PresentId) — viewer can't claim the same present twice.
|
||||
/// </summary>
|
||||
public class ViewerClaimedTutorialGift
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public string PresentId { get; set; } = string.Empty;
|
||||
public DateTime ClaimedAt { get; set; }
|
||||
|
||||
public Viewer Viewer { get; set; } = null!;
|
||||
}
|
||||
@@ -7,7 +7,14 @@ public class ViewerClassData
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public int Exp { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Per-class "use random leader skin from owned pool" preference. Defaults to false.
|
||||
/// No client-side setter exists today (only per-deck random-leader-skin endpoints exist);
|
||||
/// persisted now so when/if a class-level toggle is discovered, the write target exists.
|
||||
/// </summary>
|
||||
public bool IsRandomLeaderSkin { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
public ClassEntry Class { get; set; } = new ClassEntry();
|
||||
|
||||
15
SVSim.Database/Models/ViewerFreePackClaim.cs
Normal file
15
SVSim.Database/Models/ViewerFreePackClaim.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (viewer, free_gacha_campaign_id). Counts claims and remembers when the last one
|
||||
/// landed so the controller can gate the daily quota. Owned collection on <see cref="Viewer"/>.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class ViewerFreePackClaim
|
||||
{
|
||||
public int FreeGachaCampaignId { get; set; }
|
||||
public int ClaimCount { get; set; }
|
||||
public DateTime LastClaimedAt { get; set; }
|
||||
}
|
||||
13
SVSim.Database/Models/ViewerFriend.cs
Normal file
13
SVSim.Database/Models/ViewerFriend.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per direction of a friendship. Approving an apply creates two rows
|
||||
/// (A → B and B → A). <see cref="FriendViewerId"/> from a played-together row
|
||||
/// can be self-joined against this table to detect an existing friendship.
|
||||
/// </summary>
|
||||
public class ViewerFriend
|
||||
{
|
||||
public long OwnerViewerId { get; set; }
|
||||
public long FriendViewerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
17
SVSim.Database/Models/ViewerFriendApply.cs
Normal file
17
SVSim.Database/Models/ViewerFriendApply.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One pending friend application. <see cref="Id"/> is the wire <c>apply_id</c>
|
||||
/// (auto-generated). Unique on <c>(FromViewerId, ToViewerId)</c> — a viewer can only
|
||||
/// have one outstanding apply to any given target.
|
||||
/// </summary>
|
||||
public class ViewerFriendApply
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public long FromViewerId { get; set; }
|
||||
public long ToViewerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Beginner-friend campaign tag. Defaults to 0 (no campaign). Surfaces as optional <c>mission_type</c> on the wire.</summary>
|
||||
public int MissionType { get; set; }
|
||||
}
|
||||
17
SVSim.Database/Models/ViewerPlayedTogether.cs
Normal file
17
SVSim.Database/Models/ViewerPlayedTogether.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (owner, opponent) pair. Upserted on each new battle so the table
|
||||
/// holds at most one row per opponent. Per-viewer 50-row retention cap pruned
|
||||
/// by <c>IPlayedTogetherWriter.RecordAsync</c>.
|
||||
/// </summary>
|
||||
public class ViewerPlayedTogether
|
||||
{
|
||||
public long OwnerViewerId { get; set; }
|
||||
public long OpponentViewerId { get; set; }
|
||||
public DateTime PlayedAt { get; set; }
|
||||
public int PlayedMode { get; set; }
|
||||
public int BattleType { get; set; }
|
||||
public int DeckFormat { get; set; }
|
||||
public int TwoPickType { get; set; }
|
||||
}
|
||||
48
SVSim.Database/Models/ViewerPresent.cs
Normal file
48
SVSim.Database/Models/ViewerPresent.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per gift in a viewer's inbox. Replaces the tutorial-only
|
||||
/// <c>ViewerClaimedTutorialGift</c> receipts model with a unified status-enum row that
|
||||
/// serves both /gift/top + /gift/receive_gift (prod) and /tutorial/gift_top +
|
||||
/// /tutorial/gift_receive (tutorial alias).
|
||||
/// </summary>
|
||||
public class ViewerPresent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long ViewerId { get; set; }
|
||||
public Viewer Viewer { get; set; } = null!;
|
||||
|
||||
/// <summary>Wire id ("71409625" in the prod capture). String to match the wire.</summary>
|
||||
public string PresentId { get; set; } = string.Empty;
|
||||
|
||||
public PresentStatus Status { get; set; }
|
||||
|
||||
/// <summary>UserGoodsType-compatible int. Wire is stringified — see PresentMapper.</summary>
|
||||
public int RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public long RewardCount { get; set; }
|
||||
public int ConditionNumber { get; set; }
|
||||
public int PresentLimitType { get; set; }
|
||||
public long RewardLimitTime { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ClaimedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Free-form provenance tag for future producers ("tutorial", "challenge_win",
|
||||
/// "payment_refund:<txid>", "event:<id>"). Nothing in the receive handler reads
|
||||
/// this today — the tutorial-step advance is route-gated, not Source-gated.
|
||||
/// </summary>
|
||||
public string? Source { get; set; }
|
||||
}
|
||||
|
||||
public enum PresentStatus : byte
|
||||
{
|
||||
Unclaimed = 0,
|
||||
Claimed = 1,
|
||||
Deleted = 2,
|
||||
Expired = 3,
|
||||
}
|
||||
13
SVSim.Database/Models/ViewerSerialCodeRedemption.cs
Normal file
13
SVSim.Database/Models/ViewerSerialCodeRedemption.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (viewer, code) redemption. Composite PK on <c>(ViewerId, SerialCodeId)</c>
|
||||
/// enforces the single-use-per-viewer guarantee at the DB layer; the controller catches
|
||||
/// the unique-constraint violation as a race-condition backstop.
|
||||
/// </summary>
|
||||
public class ViewerSerialCodeRedemption
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public int SerialCodeId { get; set; }
|
||||
public DateTime RedeemedAt { get; set; }
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
|
||||
// Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
|
||||
// card grants with cosmetic cascade.
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.CardCraft);
|
||||
|
||||
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
|
||||
if (!spendResult.Success)
|
||||
|
||||
@@ -65,6 +65,13 @@ public class GlobalsRepository : IGlobalsRepository
|
||||
public Task<List<BannerEntry>> GetBanners() =>
|
||||
_dbContext.Banners.AsNoTracking().OrderBy(b => b.Id).ToListAsync();
|
||||
|
||||
public async Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc) =>
|
||||
await _dbContext.HomeDialogEntries.AsNoTracking()
|
||||
.Where(e => e.BeginTime <= nowUtc && e.EndTime > nowUtc)
|
||||
.OrderByDescending(e => e.Priority)
|
||||
.ThenBy(e => e.Id)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<ColosseumConfig?> GetCurrentColosseum() =>
|
||||
_dbContext.Colosseums.AsNoTracking().FirstOrDefaultAsync(e => e.Id == 1);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IGlobalsRepository
|
||||
Task<List<BattlePassLevelEntry>> GetBattlePassLevels();
|
||||
Task<List<DailyLoginBonusEntry>> GetDailyLoginBonus();
|
||||
Task<List<BannerEntry>> GetBanners();
|
||||
Task<IReadOnlyList<HomeDialogEntry>> GetActiveHomeDialogsAsync(DateTime nowUtc);
|
||||
Task<ColosseumConfig?> GetCurrentColosseum();
|
||||
Task<SealedConfig?> GetCurrentSealedSeason();
|
||||
Task<MasterPointRankingPeriodEntry?> GetCurrentMasterPointPeriod();
|
||||
|
||||
@@ -66,6 +66,7 @@ public class ViewerRepository : IViewerRepository
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins).ThenInclude(ls => ls.Class)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.MyPageBgRotation)
|
||||
.FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid);
|
||||
}
|
||||
|
||||
@@ -110,6 +111,36 @@ public class ViewerRepository : IViewerRepository
|
||||
var viewer = await BuildDefaultViewer("");
|
||||
viewer.Udid = udid;
|
||||
_dbContext.Set<Models.Viewer>().Add(viewer);
|
||||
|
||||
// Eager-seed the tutorial gifts so the inbox is populated by the time the tutorial
|
||||
// walks the user to it (which happens AFTER initial battles, per the gift-inbox
|
||||
// design). The catalogue lives in TutorialPresentEntries (loaded from
|
||||
// SVSim.Bootstrap/Data/seeds/tutorial-presents.json); if the catalogue is empty
|
||||
// (bootstrap not run) signup still succeeds with an empty inbox. The unique
|
||||
// (ViewerId, PresentId) index is the backstop against double-seeding on a retried
|
||||
// signup. Both inserts commit in a single SaveChanges.
|
||||
var tutorialPresents = await _dbContext.Set<TutorialPresentEntry>()
|
||||
.AsNoTracking()
|
||||
.OrderBy(p => p.PresentId)
|
||||
.ToListAsync();
|
||||
var createdAt = DateTime.UtcNow;
|
||||
foreach (var spec in tutorialPresents)
|
||||
{
|
||||
_dbContext.Set<ViewerPresent>().Add(new ViewerPresent
|
||||
{
|
||||
Viewer = viewer, // EF wires up ViewerId via the nav after Insert
|
||||
PresentId = spec.PresentId,
|
||||
Status = PresentStatus.Unclaimed,
|
||||
RewardType = spec.RewardType,
|
||||
RewardDetailId = spec.RewardDetailId,
|
||||
RewardCount = spec.RewardCount,
|
||||
ItemType = spec.ItemType,
|
||||
Message = spec.Message,
|
||||
CreatedAt = createdAt,
|
||||
Source = "tutorial",
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _dbContext.SaveChangesAsync();
|
||||
@@ -272,7 +303,9 @@ public class ViewerRepository : IViewerRepository
|
||||
{
|
||||
Class = ce,
|
||||
Exp = 0,
|
||||
Level = 0,
|
||||
// Client unconditionally indexes `_classCharaExpList[level - 1]` in
|
||||
// RankMatchUI.onOpen → CharacterExps.GetClassCharacterExps; level 0 throws IOOR.
|
||||
Level = 1,
|
||||
LeaderSkin = skin ?? new LeaderSkinEntry { Id = 0, Name = "<missing>", ClassId = ce.Id }
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -62,6 +62,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<ViewerEventCounter> ViewerEventCounters => Set<ViewerEventCounter>();
|
||||
public DbSet<DailyLoginBonusEntry> DailyLoginBonuses => Set<DailyLoginBonusEntry>();
|
||||
public DbSet<BannerEntry> Banners => Set<BannerEntry>();
|
||||
public DbSet<HomeDialogEntry> HomeDialogEntries => Set<HomeDialogEntry>();
|
||||
public DbSet<ColosseumConfig> Colosseums => Set<ColosseumConfig>();
|
||||
public DbSet<SealedConfig> SealedSeasons => Set<SealedConfig>();
|
||||
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
|
||||
@@ -100,11 +101,22 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<ViewerStoryProgress> ViewerStoryProgress => Set<ViewerStoryProgress>();
|
||||
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
|
||||
|
||||
public DbSet<ViewerClaimedTutorialGift> ViewerClaimedTutorialGifts => Set<ViewerClaimedTutorialGift>();
|
||||
public DbSet<ViewerPresent> ViewerPresents => Set<ViewerPresent>();
|
||||
public DbSet<TutorialPresentEntry> TutorialPresentEntries => Set<TutorialPresentEntry>();
|
||||
public DbSet<ViewerAcquireHistoryEntry> ViewerAcquireHistory => Set<ViewerAcquireHistoryEntry>();
|
||||
|
||||
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
|
||||
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
|
||||
|
||||
public DbSet<SerialCodeEntry> SerialCodes => Set<SerialCodeEntry>();
|
||||
public DbSet<SerialCodeRewardEntry> SerialCodeRewards => Set<SerialCodeRewardEntry>();
|
||||
public DbSet<ViewerSerialCodeRedemption> ViewerSerialCodeRedemptions => Set<ViewerSerialCodeRedemption>();
|
||||
|
||||
public DbSet<ViewerFriend> ViewerFriends => Set<ViewerFriend>();
|
||||
public DbSet<ViewerFriendApply> ViewerFriendApplies => Set<ViewerFriendApply>();
|
||||
public DbSet<ViewerPlayedTogether> ViewerPlayedTogethers => Set<ViewerPlayedTogether>();
|
||||
public DbSet<ViewerBattleHistory> ViewerBattleHistories => Set<ViewerBattleHistory>();
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -163,6 +175,19 @@ public class SVSimDbContext : DbContext
|
||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
|
||||
});
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.FreePackClaims, b =>
|
||||
{
|
||||
b.WithOwner().HasForeignKey("ViewerId");
|
||||
b.HasKey("ViewerId", nameof(ViewerFreePackClaim.FreeGachaCampaignId));
|
||||
b.Property(x => x.FreeGachaCampaignId).ValueGeneratedNever();
|
||||
});
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.MyPageBgRotation, b =>
|
||||
{
|
||||
b.ToTable("ViewerMyPageBgRotation");
|
||||
b.WithOwner().HasForeignKey("ViewerId");
|
||||
b.HasKey("ViewerId", nameof(MyPageBgRotationEntry.Slot));
|
||||
b.Property(x => x.Slot).ValueGeneratedNever();
|
||||
});
|
||||
|
||||
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
|
||||
// generated, which silently permits multiple rows per (Viewer, Card) or (Viewer, Item).
|
||||
@@ -365,11 +390,110 @@ public class SVSimDbContext : DbContext
|
||||
b.HasIndex(e => new { e.ViewerId, e.Period });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerClaimedTutorialGift>(b =>
|
||||
modelBuilder.Entity<ViewerPresent>(b =>
|
||||
{
|
||||
b.HasKey(g => new { g.ViewerId, g.PresentId });
|
||||
b.HasOne(g => g.Viewer).WithMany().HasForeignKey(g => g.ViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
b.Property(g => g.PresentId).HasMaxLength(64);
|
||||
b.HasKey(p => p.Id);
|
||||
b.Property(p => p.PresentId).HasMaxLength(64);
|
||||
b.Property(p => p.Source).HasMaxLength(64);
|
||||
|
||||
b.HasOne(p => p.Viewer)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.ViewerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Drives /gift/top — partition by status, then chronological.
|
||||
b.HasIndex(p => new { p.ViewerId, p.Status, p.CreatedAt });
|
||||
|
||||
// One row per (viewer, present_id) — backstop against double-seeding and
|
||||
// double-enqueue from future producers.
|
||||
b.HasIndex(p => new { p.ViewerId, p.PresentId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TutorialPresentEntry>(b =>
|
||||
{
|
||||
b.HasKey(p => p.PresentId);
|
||||
b.Property(p => p.PresentId).HasMaxLength(64);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerAcquireHistoryEntry>(b =>
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
b.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
b.Property(e => e.Message).HasMaxLength(64);
|
||||
b.HasOne<Viewer>()
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ViewerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
b.HasIndex(e => new { e.ViewerId, e.AcquireTime, e.Id });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SerialCodeEntry>(b =>
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
b.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
b.Property(e => e.Code).HasMaxLength(64).IsRequired();
|
||||
b.Property(e => e.Message).HasMaxLength(255);
|
||||
b.HasIndex(e => e.Code).IsUnique();
|
||||
b.HasMany(e => e.Rewards)
|
||||
.WithOne()
|
||||
.HasForeignKey(r => r.SerialCodeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SerialCodeRewardEntry>(b =>
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
b.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
b.HasIndex(e => new { e.SerialCodeId, e.Slot });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerSerialCodeRedemption>(b =>
|
||||
{
|
||||
b.HasKey(e => new { e.ViewerId, e.SerialCodeId });
|
||||
b.HasOne<Viewer>()
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ViewerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
b.HasOne<SerialCodeEntry>()
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.SerialCodeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerFriend>(b =>
|
||||
{
|
||||
b.HasKey(e => new { e.OwnerViewerId, e.FriendViewerId });
|
||||
b.HasIndex(e => new { e.OwnerViewerId, e.CreatedAt });
|
||||
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FriendViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerFriendApply>(b =>
|
||||
{
|
||||
b.HasKey(e => e.Id);
|
||||
b.HasIndex(e => new { e.FromViewerId, e.ToViewerId }).IsUnique();
|
||||
b.HasIndex(e => e.ToViewerId);
|
||||
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.FromViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.ToViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerPlayedTogether>(b =>
|
||||
{
|
||||
b.HasKey(e => new { e.OwnerViewerId, e.OpponentViewerId });
|
||||
b.HasIndex(e => new { e.OwnerViewerId, e.PlayedAt });
|
||||
b.HasOne<Viewer>().WithMany().HasForeignKey(e => e.OwnerViewerId).OnDelete(DeleteBehavior.Cascade);
|
||||
// OpponentViewerId is NOT an FK — we want survivors' history to outlive a deleted opponent.
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ViewerBattleHistory>(b =>
|
||||
{
|
||||
b.HasKey(e => new { e.ViewerId, e.BattleId });
|
||||
b.HasIndex(e => new { e.ViewerId, e.CreateTime })
|
||||
.HasDatabaseName("IX_ViewerBattleHistories_ViewerId_CreateTime");
|
||||
b.Property(e => e.SelfRotationId).IsRequired();
|
||||
b.Property(e => e.OpponentName).IsRequired();
|
||||
b.Property(e => e.OpponentCountryCode).IsRequired();
|
||||
b.Property(e => e.OpponentRotationId).IsRequired();
|
||||
});
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
84
SVSim.Database/Services/Friend/FriendDtos.cs
Normal file
84
SVSim.Database/Services/Friend/FriendDtos.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace SVSim.Database.Services.Friend;
|
||||
|
||||
/// <summary>
|
||||
/// One friend in the requested viewer's friend list. Wire shape carries 15 fields;
|
||||
/// most are cosmetic ints emitted as strings (matches prod). Numeric fields
|
||||
/// (viewer_id, rank, emblem_id, degree_id) ship as native ints.
|
||||
/// </summary>
|
||||
public sealed record FriendEntry(
|
||||
int ViewerId,
|
||||
string Name,
|
||||
string CountryCode,
|
||||
int Rank,
|
||||
long EmblemId,
|
||||
int DegreeId,
|
||||
string LastPlayTime, // "yyyy-MM-dd HH:mm:ss"
|
||||
string DeviceType,
|
||||
string MaxFriend,
|
||||
string IsReceivedTwoPickMission,
|
||||
string Birth,
|
||||
string MissionChangeTime,
|
||||
string MissionReceiveType,
|
||||
string IsOfficial,
|
||||
string IsOfficialMarkDisplayed);
|
||||
|
||||
/// <summary>
|
||||
/// One friend apply (sent or received). Wire field <c>id</c> is the apply's PK.
|
||||
/// </summary>
|
||||
public sealed record FriendApplyEntry(
|
||||
int Id,
|
||||
int ViewerId, // OTHER viewer's id
|
||||
string Name,
|
||||
string CountryCode,
|
||||
int Rank,
|
||||
long EmblemId,
|
||||
int DegreeId,
|
||||
string LastPlayTime,
|
||||
string CreateTime,
|
||||
int MissionType); // 0 when omitted on the wire
|
||||
|
||||
/// <summary>
|
||||
/// One recent-opponent row. <see cref="FriendStatus"/> is computed at read time:
|
||||
/// 0 = NO_ACTION, 1 = IS_FRIEND, 2 = IS_SEND (caller has outgoing apply),
|
||||
/// 3 = IS_RECEIVED (caller has incoming apply from opponent).
|
||||
/// <see cref="FriendApplyId"/> is the relevant apply's PK when status is 2 or 3, else 0.
|
||||
/// </summary>
|
||||
public sealed record PlayedTogetherEntry(
|
||||
int ViewerId,
|
||||
string Name,
|
||||
string CountryCode,
|
||||
int Rank,
|
||||
long EmblemId,
|
||||
int DegreeId,
|
||||
string LastPlayTime,
|
||||
string PlayedTime,
|
||||
int FriendStatus,
|
||||
int FriendApplyId,
|
||||
int PlayedMode,
|
||||
int BattleType,
|
||||
int DeckFormat,
|
||||
int TwoPickType);
|
||||
|
||||
public sealed record FriendInfoResult(
|
||||
IReadOnlyList<FriendEntry> Friends,
|
||||
int Count,
|
||||
int MaxCount);
|
||||
|
||||
public sealed record ReceiveApplyInfoResult(
|
||||
IReadOnlyList<FriendApplyEntry> ReceiveApplies,
|
||||
int ApproveApplyCount);
|
||||
|
||||
public sealed record SendApplyInfoResult(
|
||||
IReadOnlyList<FriendApplyEntry> SendApplies,
|
||||
int RemainingApplyCount,
|
||||
int SendApplyMaxCount);
|
||||
|
||||
public sealed record PlayedTogetherResult(
|
||||
IReadOnlyList<PlayedTogetherEntry> Histories);
|
||||
|
||||
/// <summary>Context recorded by <see cref="IPlayedTogetherWriter.RecordAsync"/>.</summary>
|
||||
public sealed record BattleParticipationContext(
|
||||
int PlayedMode,
|
||||
int BattleType,
|
||||
int DeckFormat,
|
||||
int TwoPickType);
|
||||
404
SVSim.Database/Services/Friend/FriendService.cs
Normal file
404
SVSim.Database/Services/Friend/FriendService.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services.Friend;
|
||||
|
||||
public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
||||
{
|
||||
internal const int FriendMaxCount = 110;
|
||||
internal const int SendApplyMaxCount = 110;
|
||||
internal const int PlayedTogetherRetention = 50;
|
||||
|
||||
// Cosmetic field defaults matching the prod capture's "no campaign, normal player" state.
|
||||
internal const string DefaultDeviceType = "2";
|
||||
internal const string DefaultMaxFriend = "110";
|
||||
internal const string DefaultIsReceivedTwoPickMission = "1";
|
||||
internal const string DefaultBirth = "0";
|
||||
internal const string DefaultMissionChangeTime = "2017-09-15 02:36:09";
|
||||
internal const string DefaultMissionReceiveType = "0";
|
||||
internal const string DefaultIsOfficial = "0";
|
||||
internal const string DefaultIsOfficialMarkDisplayed = "0";
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<FriendService> _log;
|
||||
|
||||
public FriendService(SVSimDbContext db, ILogger<FriendService> log)
|
||||
{
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var friendIds = await _db.ViewerFriends
|
||||
.AsNoTracking()
|
||||
.Where(f => f.OwnerViewerId == viewerId)
|
||||
.OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId)
|
||||
.Select(f => f.FriendViewerId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var friends = new List<FriendEntry>(friendIds.Count);
|
||||
foreach (var friendId in friendIds)
|
||||
{
|
||||
var entry = await BuildFriendEntryAsync(friendId, ct);
|
||||
if (entry is not null) friends.Add(entry);
|
||||
}
|
||||
|
||||
return new FriendInfoResult(friends, friends.Count, FriendMaxCount);
|
||||
}
|
||||
|
||||
public async Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerFriendApplies
|
||||
.Where(a => a.ToViewerId == viewerId)
|
||||
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var applies = new List<FriendApplyEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct));
|
||||
|
||||
return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0);
|
||||
}
|
||||
|
||||
public async Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerFriendApplies
|
||||
.Where(a => a.FromViewerId == viewerId)
|
||||
.OrderBy(a => a.CreatedAt).ThenBy(a => a.Id)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var applies = new List<FriendApplyEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct));
|
||||
|
||||
int remaining = Math.Max(0, SendApplyMaxCount - rows.Count);
|
||||
return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount);
|
||||
}
|
||||
|
||||
public async Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerPlayedTogethers
|
||||
.Where(p => p.OwnerViewerId == viewerId)
|
||||
.OrderByDescending(p => p.PlayedAt)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var entries = new List<PlayedTogetherEntry>(rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct);
|
||||
if (opp is null) continue; // opponent deleted; skip the dead row
|
||||
|
||||
bool isFriend = await _db.ViewerFriends.AsNoTracking()
|
||||
.AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct);
|
||||
|
||||
int friendStatus = 0;
|
||||
int friendApplyId = 0;
|
||||
if (isFriend)
|
||||
{
|
||||
friendStatus = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sent = await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
|
||||
if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; }
|
||||
else
|
||||
{
|
||||
var recv = await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync(ct);
|
||||
if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; }
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new PlayedTogetherEntry(
|
||||
(int)opp.Id,
|
||||
opp.DisplayName,
|
||||
opp.CountryCode,
|
||||
ResolveRank(opp.DisplayName),
|
||||
opp.EmblemId,
|
||||
opp.DegreeId,
|
||||
FormatWireTimestamp(opp.LastLogin),
|
||||
FormatWireTimestamp(row.PlayedAt),
|
||||
friendStatus,
|
||||
friendApplyId,
|
||||
row.PlayedMode,
|
||||
row.BattleType,
|
||||
row.DeckFormat,
|
||||
row.TwoPickType));
|
||||
}
|
||||
|
||||
return new PlayedTogetherResult(entries);
|
||||
}
|
||||
|
||||
public async Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||
{
|
||||
if (targetViewerId == (int)viewerId) return null;
|
||||
return await BuildFriendEntryAsync(targetViewerId, ct);
|
||||
}
|
||||
|
||||
public async Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||
{
|
||||
if (targetViewerId == (int)viewerId)
|
||||
{
|
||||
_log.LogDebug("SendApply self-target ignored for viewer {ViewerId}", viewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
bool targetExists = await _db.Viewers.AsNoTracking().AnyAsync(v => v.Id == targetViewerId, ct);
|
||||
if (!targetExists)
|
||||
{
|
||||
_log.LogDebug("SendApply target {Target} not found", targetViewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
bool alreadyFriends = await _db.ViewerFriends.AsNoTracking()
|
||||
.AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId, ct);
|
||||
if (alreadyFriends)
|
||||
{
|
||||
_log.LogDebug("SendApply ignored — viewer {ViewerId} already friends with {Target}", viewerId, targetViewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
bool alreadyPending = await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.AnyAsync(a => a.FromViewerId == viewerId && a.ToViewerId == targetViewerId, ct);
|
||||
if (alreadyPending) return;
|
||||
|
||||
int outgoingCount = await _db.ViewerFriendApplies.CountAsync(a => a.FromViewerId == viewerId, ct);
|
||||
if (outgoingCount >= SendApplyMaxCount)
|
||||
{
|
||||
_log.LogInformation("SendApply hit cap of {Cap} for viewer {ViewerId}", SendApplyMaxCount, viewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
_db.ViewerFriendApplies.Add(new ViewerFriendApply
|
||||
{
|
||||
FromViewerId = viewerId,
|
||||
ToViewerId = targetViewerId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
MissionType = 0,
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct)
|
||||
{
|
||||
var apply = await _db.ViewerFriendApplies
|
||||
.FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct);
|
||||
if (apply is null)
|
||||
{
|
||||
_log.LogDebug("ApproveApply {ApplyId} not addressed to viewer {ViewerId}", applyId, viewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
long otherViewer = apply.FromViewerId;
|
||||
|
||||
int myFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == viewerId, ct);
|
||||
int otherFriendCount = await _db.ViewerFriends.CountAsync(f => f.OwnerViewerId == otherViewer, ct);
|
||||
if (myFriendCount >= FriendMaxCount || otherFriendCount >= FriendMaxCount)
|
||||
{
|
||||
_log.LogInformation("ApproveApply hit friend cap (me={Me}, other={Other})", myFriendCount, otherFriendCount);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
_db.ViewerFriendApplies.Remove(apply);
|
||||
|
||||
// Clean reverse-direction apply if it exists.
|
||||
var reverse = await _db.ViewerFriendApplies
|
||||
.FirstOrDefaultAsync(a => a.FromViewerId == viewerId && a.ToViewerId == otherViewer, ct);
|
||||
if (reverse is not null) _db.ViewerFriendApplies.Remove(reverse);
|
||||
|
||||
_db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = viewerId, FriendViewerId = otherViewer, CreatedAt = now });
|
||||
_db.ViewerFriends.Add(new ViewerFriend { OwnerViewerId = otherViewer, FriendViewerId = viewerId, CreatedAt = now });
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct)
|
||||
{
|
||||
var apply = await _db.ViewerFriendApplies
|
||||
.FirstOrDefaultAsync(a => a.Id == applyId && a.ToViewerId == viewerId, ct);
|
||||
if (apply is null) return;
|
||||
_db.ViewerFriendApplies.Remove(apply);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct)
|
||||
{
|
||||
var apply = await _db.ViewerFriendApplies
|
||||
.FirstOrDefaultAsync(a => a.Id == applyId && a.FromViewerId == viewerId, ct);
|
||||
if (apply is null) return;
|
||||
_db.ViewerFriendApplies.Remove(apply);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RejectAllAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
await _db.ViewerFriendApplies
|
||||
.Where(a => a.ToViewerId == viewerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task CancelAllAppliesAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
await _db.ViewerFriendApplies
|
||||
.Where(a => a.FromViewerId == viewerId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct)
|
||||
{
|
||||
var rows = await _db.ViewerFriends
|
||||
.Where(f =>
|
||||
(f.OwnerViewerId == viewerId && f.FriendViewerId == targetViewerId) ||
|
||||
(f.OwnerViewerId == targetViewerId && f.FriendViewerId == viewerId))
|
||||
.ToListAsync(ct);
|
||||
if (rows.Count == 0) return;
|
||||
_db.ViewerFriends.RemoveRange(rows);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct)
|
||||
{
|
||||
if (ownerViewerId == opponentViewerId) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await _db.ViewerPlayedTogethers
|
||||
.FirstOrDefaultAsync(p => p.OwnerViewerId == ownerViewerId && p.OpponentViewerId == opponentViewerId, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
// Enforce per-viewer retention BEFORE insert: if at cap, drop the oldest first.
|
||||
int currentCount = await _db.ViewerPlayedTogethers.CountAsync(p => p.OwnerViewerId == ownerViewerId, ct);
|
||||
if (currentCount >= PlayedTogetherRetention)
|
||||
{
|
||||
var toEvict = await _db.ViewerPlayedTogethers
|
||||
.Where(p => p.OwnerViewerId == ownerViewerId)
|
||||
.OrderBy(p => p.PlayedAt).ThenBy(p => p.OpponentViewerId)
|
||||
.FirstAsync(ct);
|
||||
_db.ViewerPlayedTogethers.Remove(toEvict);
|
||||
}
|
||||
|
||||
_db.ViewerPlayedTogethers.Add(new ViewerPlayedTogether
|
||||
{
|
||||
OwnerViewerId = ownerViewerId,
|
||||
OpponentViewerId = opponentViewerId,
|
||||
PlayedAt = now,
|
||||
PlayedMode = ctx.PlayedMode,
|
||||
BattleType = ctx.BattleType,
|
||||
DeckFormat = ctx.DeckFormat,
|
||||
TwoPickType = ctx.TwoPickType,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.PlayedAt = now;
|
||||
existing.PlayedMode = ctx.PlayedMode;
|
||||
existing.BattleType = ctx.BattleType;
|
||||
existing.DeckFormat = ctx.DeckFormat;
|
||||
existing.TwoPickType = ctx.TwoPickType;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private sealed record ViewerProjection(
|
||||
long Id,
|
||||
string DisplayName,
|
||||
DateTime LastLogin,
|
||||
string CountryCode,
|
||||
long EmblemId,
|
||||
int DegreeId);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record.
|
||||
/// We materialise the full entity rather than using Select() because EF Core
|
||||
/// ignores Include/ThenInclude when a Select projection is present.
|
||||
/// </summary>
|
||||
private async Task<ViewerProjection?> LoadViewerProjectionAsync(long viewerId, CancellationToken ct)
|
||||
{
|
||||
var v = await _db.Viewers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Id == viewerId)
|
||||
.Include(x => x.Info).ThenInclude(i => i.SelectedEmblem)
|
||||
.Include(x => x.Info).ThenInclude(i => i.SelectedDegree)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (v is null) return null;
|
||||
|
||||
return new ViewerProjection(
|
||||
v.Id,
|
||||
v.DisplayName,
|
||||
v.LastLogin,
|
||||
v.Info.CountryCode,
|
||||
v.Info.SelectedEmblem?.Id ?? 0,
|
||||
v.Info.SelectedDegree?.Id ?? 0);
|
||||
}
|
||||
|
||||
private async Task<FriendEntry?> BuildFriendEntryAsync(long friendViewerId, CancellationToken ct)
|
||||
{
|
||||
var v = await LoadViewerProjectionAsync(friendViewerId, ct);
|
||||
if (v is null) return null;
|
||||
|
||||
return new FriendEntry(
|
||||
ViewerId: (int)v.Id,
|
||||
Name: v.DisplayName,
|
||||
CountryCode: v.CountryCode,
|
||||
Rank: ResolveRank(v.DisplayName),
|
||||
EmblemId: v.EmblemId,
|
||||
DegreeId: v.DegreeId,
|
||||
LastPlayTime: FormatWireTimestamp(v.LastLogin),
|
||||
DeviceType: DefaultDeviceType,
|
||||
MaxFriend: DefaultMaxFriend,
|
||||
IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission,
|
||||
Birth: DefaultBirth,
|
||||
MissionChangeTime: DefaultMissionChangeTime,
|
||||
MissionReceiveType: DefaultMissionReceiveType,
|
||||
IsOfficial: DefaultIsOfficial,
|
||||
IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed);
|
||||
}
|
||||
|
||||
private async Task<FriendApplyEntry> BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct)
|
||||
{
|
||||
var v = await LoadViewerProjectionAsync(otherViewerId, ct);
|
||||
// If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break.
|
||||
var displayName = v?.DisplayName ?? string.Empty;
|
||||
var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch;
|
||||
var countryCode = v?.CountryCode ?? string.Empty;
|
||||
var emblemId = v?.EmblemId ?? 0;
|
||||
var degreeId = v?.DegreeId ?? 0;
|
||||
|
||||
return new FriendApplyEntry(
|
||||
Id: applyId,
|
||||
ViewerId: (int)otherViewerId,
|
||||
Name: displayName,
|
||||
CountryCode: countryCode,
|
||||
Rank: ResolveRank(displayName),
|
||||
EmblemId: emblemId,
|
||||
DegreeId: degreeId,
|
||||
LastPlayTime: FormatWireTimestamp(lastLogin),
|
||||
CreateTime: FormatWireTimestamp(createdAt),
|
||||
MissionType: missionType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands.
|
||||
/// </summary>
|
||||
private static int ResolveRank(string _) => 1;
|
||||
|
||||
private static string FormatWireTimestamp(DateTime dt) =>
|
||||
dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
30
SVSim.Database/Services/Friend/IFriendService.cs
Normal file
30
SVSim.Database/Services/Friend/IFriendService.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace SVSim.Database.Services.Friend;
|
||||
|
||||
public interface IFriendService
|
||||
{
|
||||
Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct);
|
||||
Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct);
|
||||
Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct);
|
||||
Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>Returns null when not found, self-search, or any error.</summary>
|
||||
Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>No-op if target missing, self, already friends, already-pending apply, or at outgoing-apply cap.</summary>
|
||||
Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>No-op if apply not addressed to caller, would push either side past friend cap. Cleans reverse-direction apply if present.</summary>
|
||||
Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct);
|
||||
|
||||
/// <summary>No-op if apply not addressed to caller.</summary>
|
||||
Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct);
|
||||
|
||||
/// <summary>No-op if apply not sent by caller.</summary>
|
||||
Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct);
|
||||
|
||||
Task RejectAllAppliesAsync(long viewerId, CancellationToken ct);
|
||||
Task CancelAllAppliesAsync(long viewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>Deletes both directions of the friendship (A→B and B→A).</summary>
|
||||
Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
|
||||
}
|
||||
11
SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs
Normal file
11
SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SVSim.Database.Services.Friend;
|
||||
|
||||
/// <summary>
|
||||
/// Records a recent-opponent entry on the owner viewer. Upserts the (owner, opponent)
|
||||
/// row to PlayedAt = now, enforces a 50-row per-viewer retention cap by deleting the
|
||||
/// owner's oldest row when at cap. No-op if owner equals opponent.
|
||||
/// </summary>
|
||||
public interface IPlayedTogetherWriter
|
||||
{
|
||||
Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct);
|
||||
}
|
||||
65
SVSim.Database/Services/Inventory/GrantSource.cs
Normal file
65
SVSim.Database/Services/Inventory/GrantSource.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Logical source of a grant routed through <see cref="IInventoryTransaction.GrantAsync"/>.
|
||||
/// Stored verbatim in <c>viewer_acquire_history.AcquireType</c> and surfaced on the
|
||||
/// <c>/item_acquire_history/info</c> wire as <c>acquire_type</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Values are persisted to the database — renumbering after ship requires a migration.
|
||||
/// Values 1 and 2 mirror the prod capture in
|
||||
/// <c>data_dumps/captures/traffic_prod_misc_clicking.ndjson</c>; the rest are our own.
|
||||
/// </remarks>
|
||||
public enum GrantSource
|
||||
{
|
||||
Unknown = 0,
|
||||
DailyBonus = 1,
|
||||
PackOpen = 2,
|
||||
PuzzleReward = 3,
|
||||
StoryFinish = 4,
|
||||
BattlePassClaim = 5,
|
||||
MissionReward = 6,
|
||||
ArenaTwoPickFinish = 7,
|
||||
ItemPurchase = 8,
|
||||
BuildDeckBuy = 9,
|
||||
SleeveBuy = 10,
|
||||
LeaderSkinBuy = 11,
|
||||
GachaPointExchange = 12,
|
||||
AchievementReward = 13,
|
||||
SerialCodeRedeem = 14,
|
||||
CardCosmeticCascade = 15,
|
||||
CardCraft = 16,
|
||||
// Reserved high to stay visually distinct from gameplay sources; 17–98 are intentionally unused.
|
||||
AdminGrant = 99,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-localized text written into the <c>message</c> field of an item-acquire-history row.
|
||||
/// The client renders this string verbatim, so all entries are user-facing English.
|
||||
/// </summary>
|
||||
public static class GrantSourceMessages
|
||||
{
|
||||
/// <exception cref="ArgumentOutOfRangeException">An unmapped <see cref="GrantSource"/> value was passed.</exception>
|
||||
public static string For(GrantSource source) => source switch
|
||||
{
|
||||
GrantSource.Unknown => "Unknown",
|
||||
GrantSource.DailyBonus => "Daily Bonus",
|
||||
GrantSource.PackOpen => "From buying card packs",
|
||||
GrantSource.PuzzleReward => "From puzzle reward",
|
||||
GrantSource.StoryFinish => "From story reward",
|
||||
GrantSource.BattlePassClaim => "From battle pass reward",
|
||||
GrantSource.MissionReward => "From mission reward",
|
||||
GrantSource.ArenaTwoPickFinish => "From 2Pick reward",
|
||||
GrantSource.ItemPurchase => "From shop purchase",
|
||||
GrantSource.BuildDeckBuy => "From starter set purchase",
|
||||
GrantSource.SleeveBuy => "From sleeve purchase",
|
||||
GrantSource.LeaderSkinBuy => "From leader skin purchase",
|
||||
GrantSource.GachaPointExchange => "From point exchange",
|
||||
GrantSource.AchievementReward => "From achievement reward",
|
||||
GrantSource.SerialCodeRedeem => "From serial code",
|
||||
GrantSource.CardCosmeticCascade => "Card cosmetic",
|
||||
GrantSource.CardCraft => "From card crafting",
|
||||
GrantSource.AdminGrant => "From admin grant",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), source, "Unhandled GrantSource"),
|
||||
};
|
||||
}
|
||||
15
SVSim.Database/Services/Inventory/InventoryHistoryConfig.cs
Normal file
15
SVSim.Database/Services/Inventory/InventoryHistoryConfig.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Shared knobs for the viewer-acquire-history audit log. The write-side prune cap
|
||||
/// (in <c>InventoryTransaction</c>) and the read-side page size (in
|
||||
/// <c>ItemAcquireHistoryController</c>) both reference these constants so they cannot drift.
|
||||
/// </summary>
|
||||
public static class InventoryHistoryConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum rows kept per viewer. Older rows are pruned by
|
||||
/// <c>InventoryTransaction.CommitAsync</c>; the read endpoint pages exactly this many.
|
||||
/// </summary>
|
||||
public const int RetentionRowsPerViewer = 300;
|
||||
}
|
||||
@@ -9,11 +9,23 @@ namespace SVSim.Database.Services.Inventory;
|
||||
/// Caller-supplied extra <c>.Include</c> chains on top of the canonical viewer-inventory query
|
||||
/// in <see cref="IInventoryService.BeginAsync"/>. Use to bring in extra collections needed by
|
||||
/// the calling controller (e.g. <c>MissionData</c>, <c>BuildDeckPurchases</c>).
|
||||
/// <para>
|
||||
/// Also carries the <see cref="Source"/> tag that <see cref="IInventoryTransaction.CommitAsync"/>
|
||||
/// stamps onto every <c>viewer_acquire_history</c> row written from this transaction. Callers
|
||||
/// that don't set <see cref="Source"/> end up with <see cref="GrantSource.Unknown"/> rows;
|
||||
/// grep for <c>acquire_type=0</c> in dev to find unmigrated sites.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InventoryLoadConfig
|
||||
{
|
||||
internal List<Func<IQueryable<Viewer>, IQueryable<Viewer>>> Includes { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Logical source of every grant queued in this transaction. Defaults to
|
||||
/// <see cref="GrantSource.Unknown"/>.
|
||||
/// </summary>
|
||||
public GrantSource Source { get; set; } = GrantSource.Unknown;
|
||||
|
||||
public InventoryLoadConfig WithInclude<TProperty>(
|
||||
Expression<Func<Viewer, TProperty>> path)
|
||||
{
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class InventoryService : IInventoryService
|
||||
var freeplay = _config.Get<FreeplayConfig>();
|
||||
var dbTx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log);
|
||||
return new InventoryTransaction(_db, dbTx, viewer, freeplay, loadCfg.Source, _log);
|
||||
}
|
||||
|
||||
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
|
||||
|
||||
@@ -9,10 +9,13 @@ namespace SVSim.Database.Services.Inventory;
|
||||
|
||||
internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
{
|
||||
private const int AcquireHistoryRetention = InventoryHistoryConfig.RetentionRowsPerViewer;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IDbContextTransaction _dbTx;
|
||||
private readonly ILogger _log;
|
||||
private readonly FreeplayConfig _freeplay;
|
||||
private readonly GrantSource _source;
|
||||
private bool _committed;
|
||||
|
||||
public Viewer Viewer { get; }
|
||||
@@ -29,12 +32,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
IDbContextTransaction dbTx,
|
||||
Viewer viewer,
|
||||
FreeplayConfig freeplay,
|
||||
GrantSource source,
|
||||
ILogger log)
|
||||
{
|
||||
_db = db;
|
||||
_dbTx = dbTx;
|
||||
Viewer = viewer;
|
||||
_freeplay = freeplay;
|
||||
_source = source;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -273,7 +278,14 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
{
|
||||
ThrowIfCommitted();
|
||||
|
||||
// Flush entity mutations first so audit-history rows are staged on top of post-commit state.
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
WriteAcquireHistory();
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await PruneAcquireHistoryAsync(ct);
|
||||
|
||||
await _dbTx.CommitAsync(ct);
|
||||
_committed = true;
|
||||
|
||||
@@ -282,6 +294,51 @@ internal sealed class InventoryTransaction : IInventoryTransaction
|
||||
return new InventoryCommitResult(rewardList, deltas);
|
||||
}
|
||||
|
||||
private async Task PruneAcquireHistoryAsync(CancellationToken ct)
|
||||
{
|
||||
// Two-phase: SQLite (used in tests) cannot translate Skip+OrderBy inside ExecuteDeleteAsync.
|
||||
var overflowIds = await _db.ViewerAcquireHistory
|
||||
.Where(h => h.ViewerId == Viewer.Id)
|
||||
.OrderByDescending(h => h.AcquireTime).ThenByDescending(h => h.Id)
|
||||
.Skip(AcquireHistoryRetention)
|
||||
.Select(h => h.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (overflowIds.Count == 0) return;
|
||||
|
||||
await _db.ViewerAcquireHistory
|
||||
.Where(h => overflowIds.Contains(h.Id))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
private void WriteAcquireHistory()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var primaryMessage = GrantSourceMessages.For(_source);
|
||||
var cascadeMessage = GrantSourceMessages.For(GrantSource.CardCosmeticCascade);
|
||||
|
||||
foreach (var op in _ops)
|
||||
{
|
||||
if (op is not GrantOp grant) continue;
|
||||
if (grant.Num == 0) continue; // skip synthetic post-state grants (e.g. DebitItem)
|
||||
|
||||
var rowSource = grant.IsCascade ? GrantSource.CardCosmeticCascade : _source;
|
||||
var rowMessage = grant.IsCascade ? cascadeMessage : primaryMessage;
|
||||
var detailId = IsCurrency(grant.Type) ? 0L : grant.DetailId;
|
||||
|
||||
_db.ViewerAcquireHistory.Add(new ViewerAcquireHistoryEntry
|
||||
{
|
||||
ViewerId = Viewer.Id,
|
||||
RewardType = (int)grant.Type,
|
||||
RewardDetailId = detailId,
|
||||
RewardCount = grant.Num,
|
||||
AcquireType = (int)rowSource,
|
||||
Message = rowMessage,
|
||||
AcquireTime = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<GrantedReward> BuildRewardList()
|
||||
{
|
||||
// Pass 1 — for each currency type, find the last op (spend OR grant) that touched it
|
||||
|
||||
26
SVSim.Database/Services/Replay/BattleContext.cs
Normal file
26
SVSim.Database/Services/Replay/BattleContext.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Per-viewer battle context captured at start time (do_matching/start) and consumed
|
||||
/// at finish time. Lives in <see cref="IBattleContextStore"/> for the duration of a
|
||||
/// single battle. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
|
||||
/// </summary>
|
||||
public sealed record BattleContext(
|
||||
long BattleId,
|
||||
int BattleType,
|
||||
int DeckFormat,
|
||||
int TwoPickType,
|
||||
int SelfClassId,
|
||||
int SelfSubClassId,
|
||||
int SelfCharaId,
|
||||
string SelfRotationId,
|
||||
int OpponentViewerId,
|
||||
string OpponentName,
|
||||
int OpponentClassId,
|
||||
int OpponentSubClassId,
|
||||
int OpponentCharaId,
|
||||
string OpponentCountryCode,
|
||||
long OpponentEmblemId,
|
||||
long OpponentDegreeId,
|
||||
string OpponentRotationId,
|
||||
DateTime BattleStartTime);
|
||||
73
SVSim.Database/Services/Replay/BattleHistoryWriter.cs
Normal file
73
SVSim.Database/Services/Replay/BattleHistoryWriter.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
public sealed class BattleHistoryWriter : IBattleHistoryWriter
|
||||
{
|
||||
internal const int RetentionCap = 50;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<BattleHistoryWriter> _log;
|
||||
|
||||
public BattleHistoryWriter(SVSimDbContext db, ILogger<BattleHistoryWriter> log)
|
||||
{
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct)
|
||||
{
|
||||
if (ctx is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"BattleHistoryWriter.RecordAsync called with null context for viewer {ViewerId} - " +
|
||||
"likely missed start-time Set (server restart or non-tracked family). Skipping.",
|
||||
viewerId);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await _db.ViewerBattleHistories
|
||||
.AnyAsync(h => h.ViewerId == viewerId && h.BattleId == ctx.BattleId, ct);
|
||||
if (existing) return; // idempotent
|
||||
|
||||
var count = await _db.ViewerBattleHistories
|
||||
.CountAsync(h => h.ViewerId == viewerId, ct);
|
||||
if (count >= RetentionCap)
|
||||
{
|
||||
var oldest = await _db.ViewerBattleHistories
|
||||
.Where(h => h.ViewerId == viewerId)
|
||||
.OrderBy(h => h.CreateTime)
|
||||
.FirstAsync(ct);
|
||||
_db.ViewerBattleHistories.Remove(oldest);
|
||||
}
|
||||
|
||||
_db.ViewerBattleHistories.Add(new ViewerBattleHistory
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
BattleId = ctx.BattleId,
|
||||
BattleType = ctx.BattleType,
|
||||
DeckFormat = ctx.DeckFormat,
|
||||
TwoPickType = ctx.TwoPickType,
|
||||
IsLimitTurn = 0,
|
||||
SelfClassId = ctx.SelfClassId,
|
||||
SelfSubClassId = ctx.SelfSubClassId,
|
||||
SelfCharaId = ctx.SelfCharaId,
|
||||
SelfRotationId = ctx.SelfRotationId,
|
||||
OpponentClassId = ctx.OpponentClassId,
|
||||
OpponentSubClassId = ctx.OpponentSubClassId,
|
||||
OpponentCharaId = ctx.OpponentCharaId,
|
||||
OpponentName = ctx.OpponentName,
|
||||
OpponentCountryCode = ctx.OpponentCountryCode,
|
||||
OpponentEmblemId = ctx.OpponentEmblemId,
|
||||
OpponentDegreeId = ctx.OpponentDegreeId,
|
||||
OpponentRotationId = ctx.OpponentRotationId,
|
||||
IsWin = isWin,
|
||||
BattleStartTime = ctx.BattleStartTime,
|
||||
CreateTime = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
17
SVSim.Database/Services/Replay/IBattleContextStore.cs
Normal file
17
SVSim.Database/Services/Replay/IBattleContextStore.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory per-viewer battle context store. Bridges the start-time → finish-time
|
||||
/// gap: the /finish request body carries neither battle_id nor opponent identity,
|
||||
/// so this stash holds everything the finish hook needs to compose a
|
||||
/// ViewerBattleHistory row.
|
||||
/// </summary>
|
||||
public interface IBattleContextStore
|
||||
{
|
||||
/// <summary>Store the viewer's active battle context. Overwrites any prior entry.</summary>
|
||||
void Set(long viewerId, BattleContext ctx);
|
||||
|
||||
/// <summary>Atomic read+clear. Returns null when no context (server restart,
|
||||
/// non-tracked family, already taken). Finish handlers must tolerate null.</summary>
|
||||
BattleContext? TakeFor(long viewerId);
|
||||
}
|
||||
16
SVSim.Database/Services/Replay/IBattleHistoryWriter.cs
Normal file
16
SVSim.Database/Services/Replay/IBattleHistoryWriter.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Persists battle finishes to ViewerBattleHistory for the /replay/info list view.
|
||||
/// </summary>
|
||||
public interface IBattleHistoryWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Insert a history row for (viewerId, ctx.BattleId). No-op when ctx is null
|
||||
/// (missing context = server restart mid-battle; warn-log and continue).
|
||||
/// Idempotent on the composite PK — duplicate calls skip silently.
|
||||
/// Enforces 50-row per-viewer retention by evicting the oldest CreateTime row
|
||||
/// when at cap before insert.
|
||||
/// </summary>
|
||||
Task RecordAsync(long viewerId, BattleContext? ctx, bool isWin, CancellationToken ct);
|
||||
}
|
||||
7
SVSim.Database/Services/Replay/IReplayHistoryReader.cs
Normal file
7
SVSim.Database/Services/Replay/IReplayHistoryReader.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
public interface IReplayHistoryReader
|
||||
{
|
||||
/// <summary>Newest-first by CreateTime. Caps at <paramref name="take"/> (default 50).</summary>
|
||||
Task<IReadOnlyList<ReplayHistoryEntry>> GetRecentAsync(long viewerId, int take, CancellationToken ct);
|
||||
}
|
||||
20
SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs
Normal file
20
SVSim.Database/Services/Replay/InMemoryBattleContextStore.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/>-backed in-memory store.
|
||||
/// Lives as a singleton in DI. Server restart drops in-flight contexts —
|
||||
/// acceptable per spec (history is best-effort; finish handlers warn-log
|
||||
/// and continue when context is missing).
|
||||
/// </summary>
|
||||
public sealed class InMemoryBattleContextStore : IBattleContextStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, BattleContext> _contexts = new();
|
||||
|
||||
public void Set(long viewerId, BattleContext ctx)
|
||||
=> _contexts[viewerId] = ctx;
|
||||
|
||||
public BattleContext? TakeFor(long viewerId)
|
||||
=> _contexts.TryRemove(viewerId, out var ctx) ? ctx : null;
|
||||
}
|
||||
27
SVSim.Database/Services/Replay/ReplayHistoryEntry.cs
Normal file
27
SVSim.Database/Services/Replay/ReplayHistoryEntry.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Read-side row returned by <see cref="IReplayHistoryReader"/>. The /replay/info
|
||||
/// controller maps this to its wire DTO (all-stringified per prod capture).
|
||||
/// </summary>
|
||||
public sealed record ReplayHistoryEntry(
|
||||
long BattleId,
|
||||
int BattleType,
|
||||
int DeckFormat,
|
||||
int TwoPickType,
|
||||
int IsLimitTurn,
|
||||
int SelfClassId,
|
||||
int SelfSubClassId,
|
||||
int SelfCharaId,
|
||||
string SelfRotationId,
|
||||
int OpponentClassId,
|
||||
int OpponentSubClassId,
|
||||
int OpponentCharaId,
|
||||
string OpponentName,
|
||||
string OpponentCountryCode,
|
||||
long OpponentEmblemId,
|
||||
long OpponentDegreeId,
|
||||
string OpponentRotationId,
|
||||
bool IsWin,
|
||||
DateTime BattleStartTime,
|
||||
DateTime CreateTime);
|
||||
27
SVSim.Database/Services/Replay/ReplayHistoryReader.cs
Normal file
27
SVSim.Database/Services/Replay/ReplayHistoryReader.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Services.Replay;
|
||||
|
||||
public sealed class ReplayHistoryReader : IReplayHistoryReader
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public ReplayHistoryReader(SVSimDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<ReplayHistoryEntry>> GetRecentAsync(long viewerId, int take, CancellationToken ct)
|
||||
{
|
||||
return await _db.ViewerBattleHistories
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ViewerId == viewerId)
|
||||
.OrderByDescending(h => h.CreateTime)
|
||||
.Take(take)
|
||||
.Select(h => new ReplayHistoryEntry(
|
||||
h.BattleId, h.BattleType, h.DeckFormat, h.TwoPickType, h.IsLimitTurn,
|
||||
h.SelfClassId, h.SelfSubClassId, h.SelfCharaId, h.SelfRotationId,
|
||||
h.OpponentClassId, h.OpponentSubClassId, h.OpponentCharaId,
|
||||
h.OpponentName, h.OpponentCountryCode,
|
||||
h.OpponentEmblemId, h.OpponentDegreeId, h.OpponentRotationId,
|
||||
h.IsWin, h.BattleStartTime, h.CreateTime))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class AchievementController : SVSimController
|
||||
}
|
||||
|
||||
// Open inventory tx and grant via InventoryService.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, ct);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, ct, cfg => cfg.Source = GrantSource.AchievementReward);
|
||||
|
||||
var granted = await tx.GrantAsync(
|
||||
catalogRow.RewardType,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
@@ -13,15 +17,24 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||
private readonly IMatchingResolver _resolver;
|
||||
private readonly IBattleContextStore _battleContextStore;
|
||||
private readonly IBattleHistoryWriter _historyWriter;
|
||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||
|
||||
public ArenaTwoPickBattleController(
|
||||
IArenaTwoPickService svc,
|
||||
IMatchContextBuilder matchContextBuilder,
|
||||
IMatchingResolver resolver)
|
||||
IMatchingResolver resolver,
|
||||
IBattleContextStore battleContextStore,
|
||||
IBattleHistoryWriter historyWriter,
|
||||
IPlayedTogetherWriter playedTogetherWriter)
|
||||
{
|
||||
_svc = svc;
|
||||
_matchContextBuilder = matchContextBuilder;
|
||||
_resolver = resolver;
|
||||
_battleContextStore = battleContextStore;
|
||||
_historyWriter = historyWriter;
|
||||
_playedTogetherWriter = playedTogetherWriter;
|
||||
}
|
||||
|
||||
[HttpPost("do_matching")]
|
||||
@@ -34,6 +47,38 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
{
|
||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), ct);
|
||||
|
||||
if (r.BattleId is not null && long.TryParse(r.BattleId, out var battleIdLong))
|
||||
{
|
||||
_battleContextStore.Set(vid, new BattleContext(
|
||||
BattleId: battleIdLong,
|
||||
// Two-pick wire battle_type — see docs/api-spec/common/types.ts.md
|
||||
// #battle-types. Captured prod frames use 4 for both private match
|
||||
// AND arena two-pick contexts; if a future capture disagrees, refine.
|
||||
BattleType: 4,
|
||||
DeckFormat: Format.TwoPick.ToApi(), // wire-int 10
|
||||
TwoPickType: 0, // captured "0"; refine once tracked on MatchContext
|
||||
SelfClassId: (int)ctx.ClassId, // CardClass enum
|
||||
SelfSubClassId: 0,
|
||||
SelfCharaId: int.TryParse(ctx.CharaId, out var ch) ? ch : 0,
|
||||
SelfRotationId: "0",
|
||||
// MatchContext (SVSim.BattleNode/Bridge/MatchContext.cs) does NOT carry
|
||||
// opponent identity — the resolver returns only the BattleId. Leave
|
||||
// opponent placeholders; when the two-pick matchmaking flow plumbs the
|
||||
// second player's MatchContext through to the resolver result, fill
|
||||
// these from there (and stash for both players).
|
||||
OpponentViewerId: 0,
|
||||
OpponentName: "",
|
||||
OpponentClassId: 0,
|
||||
OpponentSubClassId: 0,
|
||||
OpponentCharaId: 0,
|
||||
OpponentCountryCode: "",
|
||||
OpponentEmblemId: 0,
|
||||
OpponentDegreeId: 0,
|
||||
OpponentRotationId: "0",
|
||||
BattleStartTime: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = r.MatchingState,
|
||||
@@ -48,12 +93,30 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("finish")]
|
||||
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req)
|
||||
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req, CancellationToken ct = default)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
try
|
||||
{
|
||||
var result = await _svc.RecordBattleResultAsync(vid, req.BattleResult == 1);
|
||||
var battleCtx = _battleContextStore.TakeFor(vid);
|
||||
bool isWin = req.BattleResult == 1;
|
||||
|
||||
await _historyWriter.RecordAsync(vid, battleCtx, isWin, ct);
|
||||
|
||||
if (battleCtx is { OpponentViewerId: > 0 })
|
||||
{
|
||||
await _playedTogetherWriter.RecordAsync(
|
||||
vid,
|
||||
battleCtx.OpponentViewerId,
|
||||
new BattleParticipationContext(
|
||||
PlayedMode: 0,
|
||||
BattleType: battleCtx.BattleType,
|
||||
DeckFormat: battleCtx.DeckFormat,
|
||||
TwoPickType: battleCtx.TwoPickType),
|
||||
ct);
|
||||
}
|
||||
|
||||
var result = await _svc.RecordBattleResultAsync(vid, isWin);
|
||||
return Ok(new BattleFinishResponseDto
|
||||
{
|
||||
BattleResult = result.BattleResult,
|
||||
|
||||
@@ -171,8 +171,11 @@ public class BuildDeckController : SVSimController
|
||||
}
|
||||
|
||||
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
|
||||
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg =>
|
||||
{
|
||||
cfg.Source = GrantSource.BuildDeckBuy;
|
||||
cfg.WithInclude(v => v.BuildDeckPurchases);
|
||||
});
|
||||
var viewer = tx.Viewer;
|
||||
|
||||
// Debit currency
|
||||
|
||||
86
SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
Normal file
86
SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /campaign/* — promotional surfaces. Currently just <c>regist_serial_code</c>.
|
||||
/// </summary>
|
||||
[Route("campaign")]
|
||||
public sealed class CampaignController : SVSimController
|
||||
{
|
||||
private const int FailureResultCode = 4202;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public CampaignController(SVSimDbContext db) => _db = db;
|
||||
|
||||
[HttpPost("regist_serial_code")]
|
||||
public async Task<IActionResult> RegisterSerialCode(
|
||||
[FromBody] RegisterSerialCodeRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var code = await _db.SerialCodes
|
||||
.Include(c => c.Rewards)
|
||||
.FirstOrDefaultAsync(c => c.Code == request.SerialCode, ct);
|
||||
|
||||
if (code is null) return Fail();
|
||||
if (!code.IsEnabled) return Fail();
|
||||
if (code.StartAt is { } start && start > now) return Fail();
|
||||
if (code.EndAt is { } end && end < now) return Fail();
|
||||
|
||||
bool alreadyRedeemed = await _db.ViewerSerialCodeRedemptions
|
||||
.AnyAsync(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id, ct);
|
||||
if (alreadyRedeemed) return Fail();
|
||||
|
||||
if (code.Rewards.Any(r => !GiftRewardTypes.IsSupported(r.RewardType))) return Fail();
|
||||
|
||||
try
|
||||
{
|
||||
_db.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
SerialCodeId = code.Id,
|
||||
RedeemedAt = now,
|
||||
});
|
||||
|
||||
foreach (var reward in code.Rewards.OrderBy(r => r.Slot))
|
||||
{
|
||||
_db.ViewerPresents.Add(new ViewerPresent
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
PresentId = Guid.NewGuid().ToString("N").Substring(0, 16),
|
||||
Status = PresentStatus.Unclaimed,
|
||||
RewardType = reward.RewardType,
|
||||
RewardDetailId = reward.RewardDetailId,
|
||||
RewardCount = reward.RewardCount,
|
||||
Message = code.Message,
|
||||
CreatedAt = now,
|
||||
Source = $"serial_code:{code.Id}",
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Race: two concurrent redeems for the same (viewer, code). The composite PK
|
||||
// on ViewerSerialCodeRedemption rejects the second one; treat as already-redeemed.
|
||||
return Fail();
|
||||
}
|
||||
|
||||
return Ok(new RegisterSerialCodeResponse { IsComplete = true });
|
||||
}
|
||||
|
||||
private IActionResult Fail() => Ok(new { result_code = FailureResultCode });
|
||||
|
||||
}
|
||||
186
SVSim.EmulatedEntrypoint/Controllers/FriendController.cs
Normal file
186
SVSim.EmulatedEntrypoint/Controllers/FriendController.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Friend;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /friend/* — viewer-scoped friend system. 5 reads + 7 writes. All writes are
|
||||
/// "silent rejection" on failure (cap exceeded, not addressed to caller, etc.) — the client
|
||||
/// pass-through Parse()s don't differentiate.
|
||||
/// </summary>
|
||||
[Route("friend")]
|
||||
public sealed class FriendController : SVSimController
|
||||
{
|
||||
private readonly IFriendService _friend;
|
||||
|
||||
public FriendController(IFriendService friend) => _friend = friend;
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<FriendInfoResponse>> Info([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var result = await _friend.GetFriendsAsync(viewerId, ct);
|
||||
return new FriendInfoResponse
|
||||
{
|
||||
Friends = result.Friends.Select(ToWire).ToList(),
|
||||
FriendCount = result.Count,
|
||||
FriendMaxCount = result.MaxCount,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("receive_apply_info")]
|
||||
public async Task<ActionResult<ReceiveApplyInfoResponse>> ReceiveApplyInfo([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var result = await _friend.GetReceiveAppliesAsync(viewerId, ct);
|
||||
return new ReceiveApplyInfoResponse
|
||||
{
|
||||
ReceiveApplies = result.ReceiveApplies.Select(ToWire).ToList(),
|
||||
ApproveApplyCount = result.ApproveApplyCount,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("send_apply_info")]
|
||||
public async Task<ActionResult<SendApplyInfoResponse>> SendApplyInfo([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var result = await _friend.GetSendAppliesAsync(viewerId, ct);
|
||||
return new SendApplyInfoResponse
|
||||
{
|
||||
SendApplies = result.SendApplies.Select(ToWire).ToList(),
|
||||
RemainingApplyCount = result.RemainingApplyCount,
|
||||
SendApplyMaxCount = result.SendApplyMaxCount,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("played_together_info")]
|
||||
public async Task<ActionResult<PlayedTogetherInfoResponse>> PlayedTogetherInfo([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var result = await _friend.GetPlayedTogetherAsync(viewerId, ct);
|
||||
return new PlayedTogetherInfoResponse
|
||||
{
|
||||
Histories = result.Histories.Select(ToWire).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("search_user")]
|
||||
public async Task<ActionResult<SearchUserResponse>> SearchUser([FromBody] SearchUserRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
var hit = await _friend.SearchAsync(viewerId, req.SearchViewerId, ct);
|
||||
return new SearchUserResponse
|
||||
{
|
||||
UserInfo = hit is null ? new object() : ToWire(hit),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("send_apply")]
|
||||
public async Task<IActionResult> SendApply([FromBody] SendApplyRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.SendApplyAsync(viewerId, req.FriendId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("approve_apply")]
|
||||
public async Task<IActionResult> ApproveApply([FromBody] ApplyIdRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.ApproveApplyAsync(viewerId, req.ApplyId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("reject_apply")]
|
||||
public async Task<IActionResult> RejectApply([FromBody] ApplyIdRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.RejectApplyAsync(viewerId, req.ApplyId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("cancel_apply")]
|
||||
public async Task<IActionResult> CancelApply([FromBody] ApplyIdRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.CancelApplyAsync(viewerId, req.ApplyId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("reject_apply_all")]
|
||||
public async Task<IActionResult> RejectApplyAll([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.RejectAllAppliesAsync(viewerId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("cancel_apply_all")]
|
||||
public async Task<IActionResult> CancelApplyAll([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.CancelAllAppliesAsync(viewerId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("reject_friend")]
|
||||
public async Task<IActionResult> RejectFriend([FromBody] RejectFriendRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
await _friend.RejectFriendAsync(viewerId, req.FriendId, ct);
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
private static FriendEntryDto ToWire(FriendEntry e) => new()
|
||||
{
|
||||
DeviceType = e.DeviceType,
|
||||
Name = e.Name,
|
||||
CountryCode = e.CountryCode,
|
||||
MaxFriend = e.MaxFriend,
|
||||
LastPlayTime = e.LastPlayTime,
|
||||
IsReceivedTwoPickMission = e.IsReceivedTwoPickMission,
|
||||
Birth = e.Birth,
|
||||
MissionChangeTime = e.MissionChangeTime,
|
||||
MissionReceiveType = e.MissionReceiveType,
|
||||
IsOfficial = e.IsOfficial,
|
||||
IsOfficialMarkDisplayed = e.IsOfficialMarkDisplayed,
|
||||
ViewerId = e.ViewerId,
|
||||
Rank = e.Rank,
|
||||
EmblemId = e.EmblemId,
|
||||
DegreeId = e.DegreeId,
|
||||
};
|
||||
|
||||
private static FriendApplyEntryDto ToWire(FriendApplyEntry e) => new()
|
||||
{
|
||||
Id = e.Id,
|
||||
ViewerId = e.ViewerId,
|
||||
Name = e.Name,
|
||||
CountryCode = e.CountryCode,
|
||||
Rank = e.Rank,
|
||||
EmblemId = e.EmblemId,
|
||||
DegreeId = e.DegreeId,
|
||||
LastPlayTime = e.LastPlayTime,
|
||||
CreateTime = e.CreateTime,
|
||||
MissionType = e.MissionType,
|
||||
};
|
||||
|
||||
private static PlayedTogetherEntryDto ToWire(PlayedTogetherEntry e) => new()
|
||||
{
|
||||
ViewerId = e.ViewerId,
|
||||
Name = e.Name,
|
||||
CountryCode = e.CountryCode,
|
||||
Rank = e.Rank,
|
||||
EmblemId = e.EmblemId,
|
||||
DegreeId = e.DegreeId,
|
||||
LastPlayTime = e.LastPlayTime,
|
||||
PlayedTime = e.PlayedTime,
|
||||
FriendStatus = e.FriendStatus,
|
||||
FriendApplyId = e.FriendApplyId,
|
||||
PlayedMode = e.PlayedMode,
|
||||
BattleType = e.BattleType,
|
||||
DeckFormat = e.DeckFormat,
|
||||
TwoPickType = e.TwoPickType,
|
||||
};
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Mapping;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here —
|
||||
/// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the
|
||||
/// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed,
|
||||
/// add /gift/top and /gift/receive_gift aliases to this controller.
|
||||
/// Persistent gift inbox. /gift/top + /tutorial/gift_top are pure URL aliases over the
|
||||
/// same ViewerPresent query; /gift/receive_gift + /tutorial/gift_receive share a single
|
||||
/// ReceiveImpl whose only divergence is the route-gated tutorial-state bump.
|
||||
///
|
||||
/// Tutorial gifts are seeded as real ViewerPresent rows during /tool/signup
|
||||
/// (see ViewerRepository.RegisterAnonymousViewer) — this controller carries no static
|
||||
/// gift catalog.
|
||||
/// </summary>
|
||||
public class GiftController : SVSimController
|
||||
{
|
||||
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
|
||||
public static readonly IReadOnlyList<PresentDto> TutorialGifts = new[]
|
||||
{
|
||||
new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" },
|
||||
new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" },
|
||||
new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 },
|
||||
new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 },
|
||||
new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 },
|
||||
};
|
||||
private const int PageSize = 30;
|
||||
private const int GiftReceiveTutorialStep = 41;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IInventoryService _inv;
|
||||
@@ -35,180 +35,168 @@ public class GiftController : SVSimController
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
[HttpPost("/gift/top")]
|
||||
[HttpPost("/tutorial/gift_top")]
|
||||
public async Task<ActionResult<GiftTopResponse>> TutorialGiftTop([FromBody] GiftTopRequest request)
|
||||
public async Task<ActionResult<GiftTopResponse>> Top([FromBody] GiftTopRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var claimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId)
|
||||
.Select(g => g.PresentId)
|
||||
.ToListAsync();
|
||||
var claimed = new HashSet<string>(claimedList);
|
||||
|
||||
var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var presents = TutorialGifts
|
||||
.Where(p => !claimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var history = TutorialGifts
|
||||
.Where(p => claimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page);
|
||||
|
||||
return new GiftTopResponse
|
||||
{
|
||||
PresentList = presents,
|
||||
PresentHistoryList = history,
|
||||
LimitOverPresentList = new(),
|
||||
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
|
||||
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
|
||||
LimitOverPresentList = new(), // expiration sweep deferred — always [] for now
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("/gift/receive_gift")]
|
||||
public Task<ActionResult<GiftReceiveResponse>> Receive([FromBody] GiftReceiveRequest r)
|
||||
=> ReceiveImpl(r, advanceTutorial: false);
|
||||
|
||||
[HttpPost("/tutorial/gift_receive")]
|
||||
public async Task<ActionResult<GiftReceiveResponse>> TutorialGiftReceive([FromBody] GiftReceiveRequest request)
|
||||
public Task<ActionResult<GiftReceiveResponse>> TutorialReceive([FromBody] GiftReceiveRequest r)
|
||||
=> ReceiveImpl(r, advanceTutorial: true);
|
||||
|
||||
private async Task<ActionResult<GiftReceiveResponse>> ReceiveImpl(
|
||||
GiftReceiveRequest request, bool advanceTutorial)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var requestedIds = request.PresentIdArray.ToHashSet();
|
||||
var requested = request.PresentIdArray.ToHashSet();
|
||||
var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE
|
||||
|
||||
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
|
||||
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
|
||||
.Select(g => g.PresentId)
|
||||
// Pull only currently-Unclaimed rows matching the request — already-Claimed /
|
||||
// Deleted / Expired rows are silently ignored (idempotent retry semantics).
|
||||
var targets = await _db.ViewerPresents
|
||||
.Where(p => p.ViewerId == viewerId
|
||||
&& p.Status == PresentStatus.Unclaimed
|
||||
&& requested.Contains(p.PresentId))
|
||||
.ToListAsync();
|
||||
var alreadyClaimed = new HashSet<string>(alreadyClaimedList);
|
||||
|
||||
var toClaim = TutorialGifts
|
||||
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
|
||||
.ToList();
|
||||
|
||||
// Open inventory tx with MissionData loaded for tutorial-step advance.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure:
|
||||
cfg => cfg.WithInclude(v => v.MissionData));
|
||||
|
||||
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
|
||||
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
|
||||
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
|
||||
// reward_list must carry post-state totals (client does direct assignment).
|
||||
var rewardListEntries = new List<GiftRewardListEntry>();
|
||||
foreach (var p in toClaim)
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg =>
|
||||
{
|
||||
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
|
||||
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
|
||||
// Use the first granted entry's post-state for the top-level gift reward_list entry.
|
||||
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
|
||||
// always has exactly one element. The post-state total is already correct from tx.
|
||||
if (granted.Count > 0)
|
||||
cfg.Source = GrantSource.AdminGrant;
|
||||
cfg.WithInclude(v => v.MissionData);
|
||||
});
|
||||
|
||||
var rewardListEntries = new List<GiftRewardListEntry>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var p in targets)
|
||||
{
|
||||
if (state == 1)
|
||||
{
|
||||
rewardListEntries.Add(new GiftRewardListEntry
|
||||
var granted = await tx.GrantAsync(
|
||||
WireRewardTypeToUserGoodsType(p.RewardType),
|
||||
p.RewardDetailId,
|
||||
(int)p.RewardCount);
|
||||
|
||||
// reward_list carries POST-STATE TOTALS (client does direct assignment).
|
||||
// See project_wire_reward_list_post_state. GrantAsync already returns post-state.
|
||||
if (granted.Count > 0)
|
||||
{
|
||||
RewardType = p.RewardType,
|
||||
RewardId = p.RewardDetailId,
|
||||
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
});
|
||||
rewardListEntries.Add(new GiftRewardListEntry
|
||||
{
|
||||
RewardType = p.RewardType.ToString(CultureInfo.InvariantCulture),
|
||||
RewardId = p.RewardDetailId.ToString(CultureInfo.InvariantCulture),
|
||||
RewardNum = granted[0].RewardNum.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
p.Status = PresentStatus.Claimed;
|
||||
p.ClaimedAt = now;
|
||||
}
|
||||
else if (state == 3)
|
||||
{
|
||||
// MAIL_DELETE: no grant, no reward_list entry, no history. Tombstone the
|
||||
// row so re-deletes are idempotent under the same WHERE-Unclaimed filter.
|
||||
p.Status = PresentStatus.Deleted;
|
||||
p.ClaimedAt = now; // overload as "decided-at" — tombstone never reaches wire
|
||||
}
|
||||
}
|
||||
|
||||
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
|
||||
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
|
||||
// viewers who are already past step 41.
|
||||
const int GiftReceiveTutorialStep = 41;
|
||||
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||
{
|
||||
// Tutorial step advance — route-gated, no Source/state checks. Preserve-max so
|
||||
// replays don't downgrade viewers already past 41.
|
||||
if (advanceTutorial && tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
||||
}
|
||||
|
||||
// Persist claim receipts inside the same tx.
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in toClaim)
|
||||
{
|
||||
_db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
PresentId = p.PresentId,
|
||||
ClaimedAt = now,
|
||||
});
|
||||
}
|
||||
await tx.CommitAsync();
|
||||
await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict
|
||||
|
||||
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var allClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId)
|
||||
.Select(g => g.PresentId)
|
||||
.ToListAsync();
|
||||
var allClaimed = new HashSet<string>(allClaimedList);
|
||||
// Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds
|
||||
// from these.
|
||||
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1);
|
||||
|
||||
// Derive presentList/historyList up front so IsUnreceivedPresent can read the count
|
||||
// without re-filtering. unclaimedPresents are the gifts still on offer after this call;
|
||||
// claimedPresents are everything the viewer has ever received (this call + prior calls).
|
||||
var unclaimedPresents = TutorialGifts
|
||||
.Where(p => !allClaimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var claimedPresents = TutorialGifts
|
||||
.Where(p => allClaimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
// is_unreceived_present drives the home-screen inbox badge — must be the DB count
|
||||
// post-commit, NOT hardcoded false (hiding the badge after partial claims).
|
||||
var stillUnclaimed = await _db.ViewerPresents
|
||||
.AnyAsync(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed);
|
||||
|
||||
return new GiftReceiveResponse
|
||||
{
|
||||
CardList = new(),
|
||||
// Echo only the ids actually granted by THIS call. Building this from `requestedIds`
|
||||
// would falsely confirm a re-grant on idempotent retries: the client would re-show
|
||||
// the "received N gifts" popup and direct-assign the same post-state totals it already
|
||||
// applied, double-toasting the user. Sort ascending to match the prod-capture order.
|
||||
ReceivedIds = toClaim
|
||||
.Select(p => p.PresentId)
|
||||
.OrderBy(x => x)
|
||||
CardList = new(), // capture is []; reward_list carries the grants
|
||||
|
||||
// Echo only ids actually transitioned by THIS call — NOT requested ids, which
|
||||
// would re-fire the "received N gifts" popup on replay.
|
||||
ReceivedIds = targets
|
||||
.Select(t => t.PresentId)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
// Same idempotency contract: only the gifts granted in THIS call belong in the
|
||||
// per-reward summary list. The client uses this to drive the +N popups.
|
||||
TotalReceiveCountList = toClaim
|
||||
.Select(p => new TotalReceiveCountDto
|
||||
|
||||
// Per-gift summary for the "+N received" popup. Empty on state=3.
|
||||
TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty<ViewerPresent>())
|
||||
.Select(t => new TotalReceiveCountDto
|
||||
{
|
||||
RewardType = int.Parse(p.RewardType),
|
||||
RewardDetailId = long.Parse(p.RewardDetailId),
|
||||
RewardCount = long.Parse(p.RewardCount),
|
||||
ItemType = p.ItemType ?? 0,
|
||||
IsUsable = true,
|
||||
RewardType = t.RewardType,
|
||||
RewardDetailId = t.RewardDetailId,
|
||||
RewardCount = t.RewardCount,
|
||||
ItemType = t.ItemType ?? 0,
|
||||
IsUsable = true,
|
||||
}).ToList(),
|
||||
PresentList = unclaimedPresents,
|
||||
PresentHistoryList = claimedPresents,
|
||||
// True when there are still unclaimed gifts on offer — drives the inbox badge state.
|
||||
// Hardcoding false hid the badge after partial claims even though present_list still
|
||||
// carried unclaimed entries.
|
||||
IsUnreceivedPresent = unclaimedPresents.Count > 0,
|
||||
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
|
||||
// See project memory: project_wire_reward_list_post_state.
|
||||
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
|
||||
|
||||
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
|
||||
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
|
||||
IsUnreceivedPresent = stillUnclaimed,
|
||||
RewardList = rewardListEntries,
|
||||
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
|
||||
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
|
||||
// emitting 41 anyway would surface a regressed step to the client and desync the
|
||||
// tutorial-state machine.
|
||||
|
||||
// Echo persisted state, not a hardcoded 41 — preserve-max above keeps it stable.
|
||||
TutorialStep = tx.Viewer.MissionData.TutorialState,
|
||||
};
|
||||
}
|
||||
|
||||
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
|
||||
/// <summary>
|
||||
/// Gift wire's <c>reward_type</c> is a literal <see cref="UserGoodsType"/> integer — the
|
||||
/// client's <c>Wizard/RewardBase.cs:245</c> casts it directly to <c>UserGoods.Type</c>.
|
||||
/// Mirror that cast, validated against <see cref="GiftRewardTypes.IsSupported(int)"/>.
|
||||
/// </summary>
|
||||
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType)
|
||||
{
|
||||
1 => UserGoodsType.Crystal,
|
||||
4 => UserGoodsType.Item,
|
||||
9 => UserGoodsType.Rupy,
|
||||
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
|
||||
};
|
||||
if (!GiftRewardTypes.IsSupported(wireType))
|
||||
throw new InvalidOperationException($"Unsupported gift reward_type {wireType}");
|
||||
return (UserGoodsType)wireType;
|
||||
}
|
||||
|
||||
private static PresentDto Clone(PresentDto p, string createTime) => new()
|
||||
private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
|
||||
long viewerId, int page)
|
||||
{
|
||||
PresentId = p.PresentId,
|
||||
RewardType = p.RewardType,
|
||||
RewardDetailId = p.RewardDetailId,
|
||||
RewardCount = p.RewardCount,
|
||||
ConditionNumber = p.ConditionNumber,
|
||||
PresentLimitType = p.PresentLimitType,
|
||||
RewardLimitTime = p.RewardLimitTime,
|
||||
CreateTime = createTime,
|
||||
ItemType = p.ItemType,
|
||||
Message = p.Message,
|
||||
};
|
||||
int pageOneIndexed = Math.Max(1, page);
|
||||
int skip = (pageOneIndexed - 1) * PageSize;
|
||||
|
||||
// Unclaimed: chronological (oldest first — capture order matches this).
|
||||
var unclaimed = await _db.ViewerPresents
|
||||
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
|
||||
.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id)
|
||||
.Skip(skip).Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// History: most-recent-first (standard inbox UX).
|
||||
var history = await _db.ViewerPresents
|
||||
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Claimed)
|
||||
.OrderByDescending(p => p.ClaimedAt).ThenByDescending(p => p.Id)
|
||||
.Skip(skip).Take(PageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (unclaimed, history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.ItemAcquireHistory;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
[Route("item_acquire_history")]
|
||||
public sealed class ItemAcquireHistoryController : SVSimController
|
||||
{
|
||||
private const string WireDateFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public ItemAcquireHistoryController(SVSimDbContext db) => _db = db;
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<ItemAcquireHistoryInfoResponse>> Info(
|
||||
[FromBody] ItemAcquireHistoryInfoRequest _,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var rows = await _db.ViewerAcquireHistory
|
||||
.Where(h => h.ViewerId == viewerId)
|
||||
.OrderByDescending(h => h.AcquireTime)
|
||||
.ThenByDescending(h => h.Id)
|
||||
.Take(InventoryHistoryConfig.RetentionRowsPerViewer)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new ItemAcquireHistoryInfoResponse
|
||||
{
|
||||
Histories = rows.Select(h => new ItemAcquireHistoryEntryDto
|
||||
{
|
||||
RewardType = h.RewardType.ToString(),
|
||||
RewardDetailId = h.RewardDetailId.ToString(),
|
||||
RewardCount = h.RewardCount.ToString(),
|
||||
AcquireType = h.AcquireType.ToString(),
|
||||
AcquireTime = h.AcquireTime.ToString(WireDateFormat, CultureInfo.InvariantCulture),
|
||||
Message = h.Message,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class ItemPurchaseController : SVSimController
|
||||
if (rest <= 0)
|
||||
return BadRequest(new { error = "sold_out" });
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.ItemPurchase);
|
||||
|
||||
// Debit the require side via the tx.
|
||||
var debit = await tx.TryDebitAsync(
|
||||
|
||||
@@ -177,7 +177,7 @@ public class LeaderSkinController : SVSimController
|
||||
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
|
||||
return BadRequest(new { error = "product_not_available" });
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
|
||||
|
||||
// Already-purchased = viewer owns the leader_skin this product grants.
|
||||
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
|
||||
@@ -230,7 +230,7 @@ public class LeaderSkinController : SVSimController
|
||||
if (!series.IsEnabled || series.SetSalesStatus == 0)
|
||||
return BadRequest(new { error = "set_sale_not_active" });
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
|
||||
|
||||
if (tx.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
@@ -286,7 +286,7 @@ public class LeaderSkinController : SVSimController
|
||||
if (existingClaim is not null)
|
||||
return new LeaderSkinBuyResponse { RewardList = new() };
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.LeaderSkinBuy);
|
||||
|
||||
// Must own every skin in the series to claim the bonus.
|
||||
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
|
||||
|
||||
@@ -192,7 +192,9 @@ public class LoadController : SVSimController
|
||||
.Select(d => new UserDeck(d)).ToList()
|
||||
},
|
||||
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
|
||||
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
|
||||
UserClasses = viewer.Classes.Select(vc => new UserClass(
|
||||
vc,
|
||||
viewer.LeaderSkins.Where(s => s.ClassId == vc.Class.Id).Select(s => s.Id).ToList())).ToList(),
|
||||
Sleeves = cosmetics.SleeveIds.Select(id => new SleeveIdentifier { SleeveId = id }).ToList(),
|
||||
UserEmblems = cosmetics.EmblemIds.Select(id => new EmblemIdentifier { EmblemId = id }).ToList(),
|
||||
UserDegrees = cosmetics.DegreeIds.Select(id => new DegreeIdentifier { DegreeId = id }).ToList(),
|
||||
|
||||
@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
@@ -24,14 +25,17 @@ public class MyPageController : SVSimController
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
|
||||
private readonly IHomeDialogSessionTracker _homeDialogTracker;
|
||||
|
||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
||||
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
|
||||
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns,
|
||||
IHomeDialogSessionTracker homeDialogTracker)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_config = config;
|
||||
_arenaTwoPickRuns = arenaTwoPickRuns;
|
||||
_homeDialogTracker = homeDialogTracker;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -59,6 +63,17 @@ public class MyPageController : SVSimController
|
||||
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
||||
var bannerEntries = await _globalsRepository.GetBanners();
|
||||
var specialDeckFormats = await _globalsRepository.GetActiveSpecialDeckFormats();
|
||||
var activeHomeDialogs = await _globalsRepository.GetActiveHomeDialogsAsync(DateTime.UtcNow);
|
||||
|
||||
var homeDialogList = new List<Models.Dtos.Common.HomeDialog>();
|
||||
foreach (var entry in activeHomeDialogs)
|
||||
{
|
||||
if (_homeDialogTracker.TryReserve(viewer.ShortUdid, entry.Id))
|
||||
{
|
||||
homeDialogList.Add(BuildHomeDialog(entry));
|
||||
break; // Client only reads [0]; emit at most one per call.
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining stubs are tagged TODO(mypage-stub) — see docs/api-spec/endpoints/post-login/mypage-index.md.
|
||||
return new MyPageIndexResponse
|
||||
@@ -97,9 +112,17 @@ public class MyPageController : SVSimController
|
||||
Quest = new Quest(), // TODO(mypage-stub): active Quest event + viewer flags
|
||||
MasterPointRankingPeriod = BuildMasterPointRankingPeriod(masterPointPeriod),
|
||||
PreReleaseStatus = 0, // TODO(mypage-stub): derive from PreReleaseInfo
|
||||
UserMyPageInfo = new UserMyPageInfo // TODO(mypage-stub): viewer mypage BG selection
|
||||
UserMyPageInfo = new UserMyPageInfo
|
||||
{
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
UserMyPageSetting = new MyPageBgSetting
|
||||
{
|
||||
MyPageId = viewer.MyPageBgId.ToString(),
|
||||
SelectType = viewer.MyPageBgSelectType.ToString(),
|
||||
MyPageIdList = viewer.MyPageBgRotation
|
||||
.OrderBy(r => r.Slot)
|
||||
.Select(r => r.BgId.ToString())
|
||||
.ToList(),
|
||||
},
|
||||
},
|
||||
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
@@ -110,6 +133,7 @@ public class MyPageController : SVSimController
|
||||
// out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack.
|
||||
// Populate from viewer.Items so the client's dict stays in sync with the DB.
|
||||
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
|
||||
HomeDialogList = homeDialogList,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||
@@ -268,6 +292,32 @@ public class MyPageController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the jsonb button_list column into wire-shape DTOs. Truncates >3 buttons —
|
||||
/// the client's switch in MyPageHomeDialog.InitializeButtonAction only handles 0/1/2/3,
|
||||
/// extras would be silently ignored anyway; doing it server-side keeps the wire honest.
|
||||
/// </summary>
|
||||
private static Models.Dtos.Common.HomeDialog BuildHomeDialog(HomeDialogEntry row)
|
||||
{
|
||||
List<Models.Dtos.Common.HomeDialogButtonDto> buttons = new();
|
||||
if (!string.IsNullOrEmpty(row.ButtonListJson) && row.ButtonListJson != "[]")
|
||||
{
|
||||
buttons = JsonSerializer.Deserialize<List<Models.Dtos.Common.HomeDialogButtonDto>>(
|
||||
row.ButtonListJson, JsonbReadOptions.Instance) ?? new();
|
||||
}
|
||||
if (buttons.Count > 3)
|
||||
{
|
||||
buttons = buttons.Take(3).ToList();
|
||||
}
|
||||
return new Models.Dtos.Common.HomeDialog
|
||||
{
|
||||
Type = row.Type?.ToString(CultureInfo.InvariantCulture),
|
||||
TitleTextId = row.TitleTextId,
|
||||
Image = row.Image,
|
||||
ButtonList = buttons,
|
||||
};
|
||||
}
|
||||
|
||||
private static BannerInfo BuildBannerInfo(BannerEntry row)
|
||||
{
|
||||
List<string> imagePaths = new();
|
||||
|
||||
@@ -92,10 +92,19 @@ public class PackController : SVSimController
|
||||
.Select(b => new { b.PackId, b.Points })
|
||||
.ToDictionaryAsync(x => x.PackId, x => x.Points);
|
||||
|
||||
// Per-viewer free-pack claim records, keyed by campaign id. Drives the
|
||||
// "drop the type_detail=10 child once today's quota is spent" filter in ToDto.
|
||||
// Plain projection — no owned-entity tracking needed (mirrors the items query above).
|
||||
var freeClaimsByCampaignId = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.FreePackClaims)
|
||||
.Select(c => new { c.FreeGachaCampaignId, c.LastClaimedAt, c.ClaimCount })
|
||||
.ToDictionaryAsync(x => x.FreeGachaCampaignId, x => (x.LastClaimedAt, x.ClaimCount));
|
||||
|
||||
return new PackInfoResponse
|
||||
{
|
||||
PackConfigList = packs
|
||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId))
|
||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId, freeClaimsByCampaignId))
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
@@ -104,14 +113,32 @@ public class PackController : SVSimController
|
||||
PackConfigEntry p,
|
||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||||
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId)
|
||||
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId,
|
||||
IReadOnlyDictionary<int, (DateTime LastClaimedAt, int ClaimCount)> freeClaimsByCampaignId)
|
||||
{
|
||||
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
|
||||
|
||||
// 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;
|
||||
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;
|
||||
int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1;
|
||||
return claim.ClaimCount < dailyCap;
|
||||
}
|
||||
var visibleChildren = p.ChildGachas.Where(ChildAvailable).ToList();
|
||||
|
||||
// Ticket-only pack: every child is TICKET (4) or TICKET_MULTI (5). These are
|
||||
// gifted-currency packs (tutorial starter, throwback) that don't participate in
|
||||
// gacha-point accrual or exchange, even if GachaPointConfig is set in seed.
|
||||
bool isTicketOnly = p.ChildGachas.All(c => c.TypeDetail == 4 || c.TypeDetail == 5);
|
||||
bool isTicketOnly = visibleChildren.All(c =>
|
||||
c.TypeDetail == CardPackType.Ticket || c.TypeDetail == CardPackType.TicketMulti);
|
||||
|
||||
PackGachaPointDto? gachaPointDto = null;
|
||||
if (p.GachaPointConfig is not null && !isTicketOnly)
|
||||
@@ -145,10 +172,10 @@ public class PackController : SVSimController
|
||||
DialogTitle = b.DialogTitle,
|
||||
}).ToList(),
|
||||
GachaDetail = p.GachaDetail,
|
||||
ChildGachaInfo = p.ChildGachas.Select(c => new PackChildGachaDto
|
||||
ChildGachaInfo = visibleChildren.Select(c => new PackChildGachaDto
|
||||
{
|
||||
GachaId = c.GachaId,
|
||||
TypeDetail = c.TypeDetail,
|
||||
TypeDetail = (int)c.TypeDetail,
|
||||
Cost = c.Cost,
|
||||
Count = c.CardCount,
|
||||
ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
|
||||
@@ -164,6 +191,14 @@ public class PackController : SVSimController
|
||||
: 0,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
|
||||
CampaignName = c.CampaignName,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount > 0
|
||||
? c.PurchaseLimitCount.ToString(CultureInfo.InvariantCulture)
|
||||
: null,
|
||||
DailyFreeGachaCount = c.DailyFreeGachaCount > 0
|
||||
? c.DailyFreeGachaCount.ToString(CultureInfo.InvariantCulture)
|
||||
: null,
|
||||
FreeGachaCampaignId = c.FreeGachaCampaignId,
|
||||
}).ToList(),
|
||||
OpenCount = openCount,
|
||||
OpenCountLimit = p.OpenCountLimit,
|
||||
@@ -204,9 +239,12 @@ public class PackController : SVSimController
|
||||
|
||||
// Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
|
||||
// (needed by TryExchangeAsync to validate balance and already-received guard).
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
|
||||
.WithInclude(v => v.GachaPointBalances)
|
||||
.WithInclude(v => v.GachaPointReceived));
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg =>
|
||||
{
|
||||
cfg.Source = GrantSource.GachaPointExchange;
|
||||
cfg.WithInclude(v => v.GachaPointBalances);
|
||||
cfg.WithInclude(v => v.GachaPointReceived);
|
||||
});
|
||||
|
||||
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
|
||||
// live. Mirrors the GetGachaPointRewards fix.
|
||||
@@ -264,21 +302,28 @@ 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 type_details on the normal path:
|
||||
// 1 CRYSTAL / 2 CRYSTAL_MULTI -> spend crystals
|
||||
// 6 RUPY / 7 RUPY_MULTI -> spend rupees
|
||||
// 3 DAILY -> spend rupees, once per UTC day
|
||||
// 4 TICKET / 5 TICKET_MULTI -> consume child.ItemId from OwnedItemEntry
|
||||
// Skin-overload types (8/9/13) and free-pack overlays (10/11/12) need extra
|
||||
// selection / banner plumbing — kept 501 until the relevant flows land.
|
||||
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
|
||||
// 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.
|
||||
// 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 (
|
||||
CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
|
||||
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
|
||||
CardPackType.RupyMulti or CardPackType.FreePacks))
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
|
||||
|
||||
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
|
||||
.WithInclude(v => v.PackOpenCounts)
|
||||
.WithInclude(v => v.GachaPointBalances)
|
||||
.WithInclude(v => v.MissionData));
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg =>
|
||||
{
|
||||
cfg.Source = GrantSource.PackOpen;
|
||||
cfg.WithInclude(v => v.PackOpenCounts);
|
||||
cfg.WithInclude(v => v.GachaPointBalances);
|
||||
cfg.WithInclude(v => v.MissionData);
|
||||
cfg.WithInclude(v => v.FreePackClaims);
|
||||
});
|
||||
var viewer = tx.Viewer;
|
||||
|
||||
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
|
||||
@@ -296,23 +341,23 @@ public class PackController : SVSimController
|
||||
{
|
||||
switch (child.TypeDetail)
|
||||
{
|
||||
case 1: // CRYSTAL (single)
|
||||
case 2: // CRYSTAL_MULTI (10-pack)
|
||||
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 6: // RUPY (single)
|
||||
case 7: // RUPY_MULTI (10-pack)
|
||||
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 3: // DAILY single — once per UTC day
|
||||
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.
|
||||
@@ -326,8 +371,8 @@ public class PackController : SVSimController
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case 4: // TICKET (single)
|
||||
case 5: // TICKET_MULTI (10-pack)
|
||||
case CardPackType.Ticket:
|
||||
case CardPackType.TicketMulti:
|
||||
{
|
||||
if (child.ItemId is not long ticketItemId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
|
||||
@@ -337,6 +382,42 @@ public class PackController : SVSimController
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +427,7 @@ public class PackController : SVSimController
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||
if (child.TypeDetail == 3)
|
||||
if (child.TypeDetail == CardPackType.Daily)
|
||||
{
|
||||
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
51
SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
Normal file
51
SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /profile/* — viewer-scoped profile read endpoint. Surfaces total rank-match wins
|
||||
/// and the per-class roster (level, exp, leader-skin selection).
|
||||
/// </summary>
|
||||
[Route("profile")]
|
||||
public sealed class ProfileController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
|
||||
public ProfileController(IViewerRepository viewerRepository) =>
|
||||
_viewerRepository = viewerRepository;
|
||||
|
||||
[HttpPost("index")]
|
||||
public async Task<ActionResult<ProfileIndexResponse>> Index(
|
||||
[FromBody] ProfileIndexRequest _,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
||||
return Unauthorized();
|
||||
|
||||
var viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null) return NotFound();
|
||||
|
||||
var skinsByClass = viewer.LeaderSkins
|
||||
.Where(s => s.ClassId.HasValue)
|
||||
.GroupBy(s => s.ClassId!.Value)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyCollection<int>)g.Select(s => s.Id).ToList());
|
||||
|
||||
var classes = viewer.Classes
|
||||
.Select(vc => new UserClass(
|
||||
vc,
|
||||
skinsByClass.GetValueOrDefault(vc.Class.Id, Array.Empty<int>())))
|
||||
.ToList();
|
||||
|
||||
return new ProfileIndexResponse
|
||||
{
|
||||
// TODO: when rank-match results are tracked, compute from viewer's rank history.
|
||||
UserRankMatchTotalWin = 0,
|
||||
UserClassList = classes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ public class PuzzleController : SVSimController
|
||||
|
||||
if (fresh.Count > 0)
|
||||
{
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.PuzzleReward);
|
||||
|
||||
foreach (var status in fresh)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,10 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
@@ -27,6 +30,9 @@ public sealed class RankBattleController : ControllerBase
|
||||
private readonly IBattleSessionStore _sessionStore;
|
||||
private readonly IMatchContextBuilder _ctxBuilder;
|
||||
private readonly IBotRoster _botRoster;
|
||||
private readonly IBattleContextStore _battleContextStore;
|
||||
private readonly IBattleHistoryWriter _historyWriter;
|
||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||
private readonly ILogger<RankBattleController> _log;
|
||||
|
||||
public RankBattleController(
|
||||
@@ -34,12 +40,18 @@ public sealed class RankBattleController : ControllerBase
|
||||
IBattleSessionStore sessionStore,
|
||||
IMatchContextBuilder ctxBuilder,
|
||||
IBotRoster botRoster,
|
||||
IBattleContextStore battleContextStore,
|
||||
IBattleHistoryWriter historyWriter,
|
||||
IPlayedTogetherWriter playedTogetherWriter,
|
||||
ILogger<RankBattleController> log)
|
||||
{
|
||||
_resolver = resolver;
|
||||
_sessionStore = sessionStore;
|
||||
_ctxBuilder = ctxBuilder;
|
||||
_botRoster = botRoster;
|
||||
_battleContextStore = battleContextStore;
|
||||
_historyWriter = historyWriter;
|
||||
_playedTogetherWriter = playedTogetherWriter;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -79,9 +91,29 @@ public sealed class RankBattleController : ControllerBase
|
||||
[HttpPost("/unlimited_rank_battle/finish")]
|
||||
[HttpPost("/ai_rotation_rank_battle/finish")]
|
||||
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
||||
public IActionResult Finish([FromBody] RankBattleFinishRequestDto req)
|
||||
public async Task<IActionResult> Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var _)) return Unauthorized();
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
|
||||
var ctx = _battleContextStore.TakeFor(vid);
|
||||
bool isWin = req.BattleResult == 1;
|
||||
|
||||
await _historyWriter.RecordAsync(vid, ctx, isWin, ct);
|
||||
|
||||
// Played-together only fires for human PvP. AI bots have OpponentViewerId=0.
|
||||
if (ctx is { OpponentViewerId: > 0 })
|
||||
{
|
||||
await _playedTogetherWriter.RecordAsync(
|
||||
vid,
|
||||
ctx.OpponentViewerId,
|
||||
new BattleParticipationContext(
|
||||
PlayedMode: 0,
|
||||
BattleType: ctx.BattleType,
|
||||
DeckFormat: ctx.DeckFormat,
|
||||
TwoPickType: ctx.TwoPickType),
|
||||
ct);
|
||||
}
|
||||
|
||||
return Ok(new RankBattleFinishResponseDto
|
||||
{
|
||||
BattleResult = req.BattleResult,
|
||||
@@ -165,6 +197,33 @@ public sealed class RankBattleController : ControllerBase
|
||||
var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
|
||||
var seed = Random.Shared.Next();
|
||||
|
||||
// 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))
|
||||
{
|
||||
_battleContextStore.Set(vid, new BattleContext(
|
||||
BattleId: battleIdLong,
|
||||
// Wire battle_type: 2 = rank battle (per docs/api-spec/common/types.ts.md
|
||||
// #battle-types). AI variant shares the rank-battle wire id.
|
||||
BattleType: 2,
|
||||
DeckFormat: format.ToApi(), // wire-int via existing converter
|
||||
TwoPickType: 0,
|
||||
SelfClassId: (int)selfCtx.ClassId, // CardClass enum
|
||||
SelfSubClassId: 0,
|
||||
SelfCharaId: int.TryParse(selfCtx.CharaId, out var ch) ? ch : 0, // CharaId is string on MatchContext
|
||||
SelfRotationId: "0",
|
||||
OpponentViewerId: 0, // AI bot — not a real viewer
|
||||
OpponentName: bot.UserName,
|
||||
OpponentClassId: bot.ClassId, // int on AIBotProfile
|
||||
OpponentSubClassId: 0,
|
||||
OpponentCharaId: bot.CharaId, // int on AIBotProfile
|
||||
OpponentCountryCode: bot.CountryCode,
|
||||
OpponentEmblemId: bot.EmblemId, // int → long widen
|
||||
OpponentDegreeId: bot.DegreeId, // int → long widen
|
||||
OpponentRotationId: "0",
|
||||
BattleStartTime: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
|
||||
return Ok(new AiBattleStartResponseDto
|
||||
{
|
||||
|
||||
86
SVSim.EmulatedEntrypoint/Controllers/RankingController.cs
Normal file
86
SVSim.EmulatedEntrypoint/Controllers/RankingController.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Ranking;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /ranking/* — Rankings menu. Stub: the period picker renders a real
|
||||
/// deterministic monthly schedule, but every leaderboard returns an empty
|
||||
/// `ranking: []`. See docs/superpowers/specs/2026-06-10-ranking-stubs-design.md.
|
||||
/// </summary>
|
||||
[Route("ranking")]
|
||||
public sealed class RankingController : SVSimController
|
||||
{
|
||||
[HttpPost("get_viewable_ranking_period_list")]
|
||||
public IActionResult GetViewablePeriodList([FromBody] BaseRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(new PeriodListResponseDto
|
||||
{
|
||||
RankMatch = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.RankMatch, now)),
|
||||
MasterPoint = ToMasterPoint(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.MasterPoint, now)),
|
||||
TwoPick = ToTwoPick(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.TwoPick, now)),
|
||||
Sealed = ToBase(RankingPeriodSchedule.GenerateFor(RankingPeriodSchedule.Family.Sealed, now)),
|
||||
// Crossover arrays stay empty — captured prod returned [] for both.
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("master_point_rotation_info")]
|
||||
public IActionResult MasterPointRotation([FromBody] MasterPointInfoRequestDto req)
|
||||
=> RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
|
||||
|
||||
[HttpPost("master_point_unlimited_info")]
|
||||
public IActionResult MasterPointUnlimited([FromBody] MasterPointInfoRequestDto req)
|
||||
=> RankingFor(RankingPeriodSchedule.Family.MasterPoint, req.PeriodId);
|
||||
|
||||
[HttpPost("rank_match_class_win_rotation_info")]
|
||||
public IActionResult RankMatchClassWinRotation([FromBody] ClassWinInfoRequestDto req)
|
||||
=> RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
|
||||
|
||||
[HttpPost("rank_match_class_win_unlimited_info")]
|
||||
public IActionResult RankMatchClassWinUnlimited([FromBody] ClassWinInfoRequestDto req)
|
||||
=> RankingFor(RankingPeriodSchedule.Family.RankMatch, req.PeriodId);
|
||||
|
||||
[HttpPost("two_pick_win_info")]
|
||||
public IActionResult TwoPickWin([FromBody] TwoPickWinInfoRequestDto req)
|
||||
=> RankingFor(RankingPeriodSchedule.Family.TwoPick, req.PeriodId);
|
||||
|
||||
private IActionResult RankingFor(RankingPeriodSchedule.Family family, int periodId)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
var entry = RankingPeriodSchedule.TryFindById(family, periodId, DateTime.UtcNow);
|
||||
var periodDto = entry is null
|
||||
? new PeriodEntryDto { Id = periodId.ToString() }
|
||||
: new PeriodEntryDto
|
||||
{
|
||||
Id = entry.Id,
|
||||
PeriodNum = entry.PeriodNum,
|
||||
BeginTime = entry.BeginTime,
|
||||
EndTime = entry.EndTime,
|
||||
};
|
||||
return Ok(new MonthlyRankingResponseDto { Period = periodDto, Ranking = new() });
|
||||
}
|
||||
|
||||
private static List<PeriodEntryDto> ToBase(IReadOnlyList<PeriodEntry> src)
|
||||
=> src.Select(e => new PeriodEntryDto
|
||||
{
|
||||
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
|
||||
}).ToList();
|
||||
|
||||
private static List<MasterPointPeriodEntryDto> ToMasterPoint(IReadOnlyList<PeriodEntry> src)
|
||||
=> src.Select(e => new MasterPointPeriodEntryDto
|
||||
{
|
||||
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
|
||||
NecessaryScore = "0",
|
||||
}).ToList();
|
||||
|
||||
private static List<TwoPickPeriodEntryDto> ToTwoPick(IReadOnlyList<PeriodEntry> src)
|
||||
=> src.Select(e => new TwoPickPeriodEntryDto
|
||||
{
|
||||
Id = e.Id, PeriodNum = e.PeriodNum, BeginTime = e.BeginTime, EndTime = e.EndTime,
|
||||
Type = "2", Over460 = "1",
|
||||
}).ToList();
|
||||
}
|
||||
69
SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
Normal file
69
SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Replay menu — recent-battles list + per-battle detail stub.
|
||||
/// /replay/info returns up to 50 rows newest-first from ViewerBattleHistories.
|
||||
/// /replay/detail returns 400 (result_code=99) — local cache is the canonical
|
||||
/// playback source; this endpoint is only hit on cache miss, and we don't store
|
||||
/// replay payloads. The client (ReplayDialogContent.GoReplay) aborts the scene
|
||||
/// transition cleanly on non-success.
|
||||
/// </summary>
|
||||
[Route("replay")]
|
||||
public sealed class ReplayController : SVSimController
|
||||
{
|
||||
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private readonly IReplayHistoryReader _reader;
|
||||
|
||||
public ReplayController(IReplayHistoryReader reader) => _reader = reader;
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<IActionResult> Info([FromBody] BaseRequest _, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
|
||||
var rows = await _reader.GetRecentAsync(vid, take: 50, ct);
|
||||
var resp = new ReplayInfoResponseDto
|
||||
{
|
||||
ReplayList = rows.Select(MapToWire).ToList(),
|
||||
};
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
[HttpPost("detail")]
|
||||
public IActionResult Detail([FromBody] ReplayDetailRequestDto req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
return BadRequest(new { result_code = 99 });
|
||||
}
|
||||
|
||||
private static ReplayInfoItemDto MapToWire(ReplayHistoryEntry e) => new()
|
||||
{
|
||||
BattleType = e.BattleType.ToString(CultureInfo.InvariantCulture),
|
||||
TwoPickType = e.TwoPickType.ToString(CultureInfo.InvariantCulture),
|
||||
DeckFormat = e.DeckFormat.ToString(CultureInfo.InvariantCulture),
|
||||
BattleId = e.BattleId.ToString(CultureInfo.InvariantCulture),
|
||||
IsLimitTurn = e.IsLimitTurn.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentName = e.OpponentName,
|
||||
ClassId = e.SelfClassId.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentClassId = e.OpponentClassId.ToString(CultureInfo.InvariantCulture),
|
||||
SubClassId = e.SelfSubClassId.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentSubClassId = e.OpponentSubClassId.ToString(CultureInfo.InvariantCulture),
|
||||
RotationId = e.SelfRotationId,
|
||||
OpponentRotationId = e.OpponentRotationId,
|
||||
OpponentCountryCode = e.OpponentCountryCode,
|
||||
CharaId = e.SelfCharaId.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentCharaId = e.OpponentCharaId.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentEmblemId = e.OpponentEmblemId.ToString(CultureInfo.InvariantCulture),
|
||||
OpponentDegreeId = e.OpponentDegreeId.ToString(CultureInfo.InvariantCulture),
|
||||
IsWin = e.IsWin ? "1" : "0",
|
||||
BattleStartTime = e.BattleStartTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
|
||||
CreateTime = e.CreateTime.ToString(TimeFormat, CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
@@ -111,7 +111,7 @@ public class SleeveController : SVSimController
|
||||
if (product.SeriesId != request.SeriesId)
|
||||
return BadRequest(new { error = "series_product_mismatch" });
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg.Source = GrantSource.SleeveBuy);
|
||||
|
||||
if (tx.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
@@ -124,7 +124,7 @@ public class SpotCardExchangeController : SVSimController
|
||||
return BadRequest(new { error = "pre_release_limit_reached" });
|
||||
}
|
||||
|
||||
await using var tx = await _inv.BeginAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg.Source = GrantSource.GachaPointExchange);
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
|
||||
53
SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs
Normal file
53
SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /user_mypage/* — viewer-scoped MyPage configuration writes. Separate from the
|
||||
/// <c>/mypage/*</c> family because the wire URL family is distinct.
|
||||
/// </summary>
|
||||
[Route("user_mypage")]
|
||||
public sealed class UserMyPageController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public UserMyPageController(SVSimDbContext db) => _db = db;
|
||||
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult<UserMyPageUpdateResponse>> Update(
|
||||
[FromBody] UserMyPageUpdateRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.MyPageBgRotation)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
if (viewer is null) return NotFound();
|
||||
|
||||
viewer.MyPageBgSelectType = request.SelectType;
|
||||
viewer.MyPageBgId = ParseIdOrZero(request.MyPageId);
|
||||
|
||||
// Clear() on a loaded OwnsMany marks every tracked entry as Deleted; SaveChangesAsync
|
||||
// issues DELETEs for all old slots before inserting the new ones.
|
||||
viewer.MyPageBgRotation.Clear();
|
||||
for (int slot = 0; slot < request.MyPageIdList.Count; slot++)
|
||||
{
|
||||
viewer.MyPageBgRotation.Add(new MyPageBgRotationEntry
|
||||
{
|
||||
Slot = slot,
|
||||
BgId = ParseIdOrZero(request.MyPageIdList[slot]),
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new UserMyPageUpdateResponse();
|
||||
}
|
||||
|
||||
private static int ParseIdOrZero(string s) =>
|
||||
int.TryParse(s, out var n) ? n : 0;
|
||||
}
|
||||
30
SVSim.EmulatedEntrypoint/Mapping/PresentMapper.cs
Normal file
30
SVSim.EmulatedEntrypoint/Mapping/PresentMapper.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Globalization;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Mapping;
|
||||
|
||||
internal static class PresentMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Project a ViewerPresent row onto the wire DTO. Field-by-field stringification matches
|
||||
/// the prod capture at data_dumps/captures/traffic_event_crate_free_pack.ndjson:
|
||||
/// - present_id, reward_type, reward_detail_id, reward_count, condition_number,
|
||||
/// present_limit_type — STRINGS on the wire.
|
||||
/// - reward_limit_time, item_type — INTS on the wire.
|
||||
/// - create_time — "yyyy-MM-dd HH:mm:ss" string, gift's row-creation time (NOT now()).
|
||||
/// </summary>
|
||||
public static PresentDto ToWire(ViewerPresent row) => new()
|
||||
{
|
||||
PresentId = row.PresentId,
|
||||
RewardType = row.RewardType.ToString(CultureInfo.InvariantCulture),
|
||||
RewardDetailId = row.RewardDetailId.ToString(CultureInfo.InvariantCulture),
|
||||
RewardCount = row.RewardCount.ToString(CultureInfo.InvariantCulture),
|
||||
ConditionNumber = row.ConditionNumber.ToString(CultureInfo.InvariantCulture),
|
||||
PresentLimitType = row.PresentLimitType.ToString(CultureInfo.InvariantCulture),
|
||||
RewardLimitTime = (int)row.RewardLimitTime,
|
||||
CreateTime = row.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||
ItemType = row.ItemType,
|
||||
Message = row.Message,
|
||||
};
|
||||
}
|
||||
@@ -123,17 +123,30 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
throw;
|
||||
}
|
||||
|
||||
// Peek the decrypted msgpack as a raw dict to extract the auth tuple BEFORE the typed
|
||||
// DTO deserialize drops anything the action's DTO doesn't model. Stash the result in
|
||||
// HttpContext.Items so SteamSessionAuthenticationHandler can read it without depending
|
||||
// on the DTO shape — that's the whole point of the decoupling, see
|
||||
// docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. Failures
|
||||
// here are non-fatal: the auth handler will surface a 401 with a more specific reason
|
||||
// (missing ticket vs corrupt body) than we could from middleware.
|
||||
if (!skipEncryption)
|
||||
{
|
||||
TryStashAuthFields(context, decryptedBytes);
|
||||
}
|
||||
|
||||
var firstParam = endpointDescriptor.Parameters.FirstOrDefault();
|
||||
if (firstParam is null)
|
||||
{
|
||||
// Action method has no parameters — middleware can't bind the (encrypted+msgpacked)
|
||||
// body to anything. The codebase convention is to take a BaseRequest even for body-
|
||||
// less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a
|
||||
// specific message rather than NREing below on .ParameterType.
|
||||
// body to anything. Fail loud with a specific message rather than NREing below on
|
||||
// .ParameterType. Authed actions can declare any DTO shape (auth fields are already
|
||||
// stashed via TryStashAuthFields above); they just need ONE parameter so the binder
|
||||
// has somewhere to put the rewritten JSON body.
|
||||
throw new InvalidOperationException(
|
||||
$"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " +
|
||||
"middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " +
|
||||
"(or a derived DTO) — see other *Info/*Top actions for the convention.");
|
||||
"middleware needs at least one to bind the decrypted body. Add a request DTO " +
|
||||
"parameter — even an empty one (see ProfileIndexRequest for the minimal shape).");
|
||||
}
|
||||
Type requestType = firstParam.ParameterType;
|
||||
object? data;
|
||||
@@ -271,6 +284,54 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
context.Response.Body = originalResponsebody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>viewer_id</c> / <c>steam_id</c> / <c>steam_session_ticket</c> out of the
|
||||
/// decrypted msgpack body and stashes them in <c>HttpContext.Items[AuthFields.ContextKey]</c>.
|
||||
/// Lets the Steam handler read the auth tuple from a separate channel so action DTOs no
|
||||
/// longer need to inherit <c>BaseRequest</c> just so the handler can find the ticket.
|
||||
/// Failures (corrupt body, non-map root, missing keys) are silent on purpose: the auth
|
||||
/// handler will surface a more specific 401 reason than we can here.
|
||||
/// </summary>
|
||||
private static void TryStashAuthFields(HttpContext context, byte[] decryptedBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = MessagePackSerializer.Deserialize<Dictionary<object, object?>>(
|
||||
decryptedBytes,
|
||||
MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance));
|
||||
if (raw is null) return;
|
||||
|
||||
context.Items[Security.SteamSessionAuthentication.AuthFields.ContextKey] =
|
||||
new Security.SteamSessionAuthentication.AuthFields
|
||||
{
|
||||
ViewerId = TryGetString(raw, "viewer_id"),
|
||||
SteamId = TryGetUlong(raw, "steam_id"),
|
||||
SteamSessionTicket = TryGetString(raw, "steam_session_ticket"),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed body — auth handler will fail with its own diagnostic.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetString(Dictionary<object, object?> raw, string key) =>
|
||||
raw.TryGetValue(key, out var v) ? v as string : null;
|
||||
|
||||
private static ulong TryGetUlong(Dictionary<object, object?> raw, string key)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null) return 0;
|
||||
return v switch
|
||||
{
|
||||
ulong u => u,
|
||||
long l => unchecked((ulong)l),
|
||||
int i => unchecked((ulong)(long)i),
|
||||
uint ui => ui,
|
||||
string s => ulong.TryParse(s, out var parsed) ? parsed : 0,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless
|
||||
/// resolver understands: objects → <c>Dictionary<string, object?></c>, arrays →
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>POST /campaign/regist_serial_code</c>. Client task:
|
||||
/// <c>MyPageCodeInputTask</c> (Shadowverse_Code_2026-05-23/Wizard/MyPageCodeInputTask.cs).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RegisterSerialCodeRequest
|
||||
{
|
||||
/// <summary>User-typed serial code. Case-sensitive on the server.</summary>
|
||||
[JsonPropertyName("serial_code")]
|
||||
[Key("serial_code")]
|
||||
public string SerialCode { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
|
||||
|
||||
/// <summary>
|
||||
/// Success response shape. Failure path uses an anonymous <c>{ result_code = 4202 }</c>
|
||||
/// (mirroring AchievementController/MissionController) and bypasses this DTO.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RegisterSerialCodeResponse
|
||||
{
|
||||
[JsonPropertyName("is_complete")]
|
||||
[Key("is_complete")]
|
||||
public bool IsComplete { get; set; }
|
||||
}
|
||||
37
SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs
Normal file
37
SVSim.EmulatedEntrypoint/Models/Dtos/Common/HomeDialog.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in /mypage/index data.home_dialog_list. Client parser
|
||||
/// (Wizard/MyPageHomeDialogData.cs) only reads [0]; up to 3 buttons supported
|
||||
/// (switch on 0/1/2/3 in MyPageHomeDialog.cs).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class HomeDialog
|
||||
{
|
||||
/// <summary>Wire "type" — prod sends "1"; client parser ignores it. Stringly-typed.
|
||||
/// Null is omitted by the global WhenWritingNull policy.</summary>
|
||||
[JsonPropertyName("type")] [Key("type")] public string? Type { get; set; }
|
||||
|
||||
/// <summary>Localization key resolved client-side via Data.SystemText.Get.</summary>
|
||||
[JsonPropertyName("title_text_id")] [Key("title_text_id")] public string TitleTextId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Asset name resolved via ResourcesManager.AssetLoadPathType.UiDownLoad.</summary>
|
||||
[JsonPropertyName("image")] [Key("image")] public string Image { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("button_list")] [Key("button_list")] public List<HomeDialogButtonDto> ButtonList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class HomeDialogButtonDto
|
||||
{
|
||||
[JsonPropertyName("button_text_id")] [Key("button_text_id")] public string ButtonTextId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Scene id consumed by MyPageBannerBase.SceneChangeBySetting (e.g. "card_pack", "mission").</summary>
|
||||
[JsonPropertyName("scene")] [Key("scene")] public string Scene { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Contextual id passed to the scene (e.g. parent_gacha_id "80032"). Stringly-typed on the wire.</summary>
|
||||
[JsonPropertyName("status")] [Key("status")] public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
55
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PresentDto.cs
Normal file
55
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PresentDto.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Prod sends most numeric-looking fields as STRINGS on the gift endpoints (present_id,
|
||||
/// reward_type, reward_detail_id, reward_count, condition_number, present_limit_type).
|
||||
/// item_type is an INT. We mirror the prod shape exactly. See the capture at
|
||||
/// data_dumps/captures/traffic_event_crate_free_pack.ndjson, /gift/top response (line 18).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class PresentDto
|
||||
{
|
||||
[JsonPropertyName("present_id")]
|
||||
[Key("present_id")]
|
||||
public string PresentId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
public string RewardDetailId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("reward_count")]
|
||||
[Key("reward_count")]
|
||||
public string RewardCount { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("condition_number")]
|
||||
[Key("condition_number")]
|
||||
public string ConditionNumber { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("present_limit_type")]
|
||||
[Key("present_limit_type")]
|
||||
public string PresentLimitType { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("reward_limit_time")]
|
||||
[Key("reward_limit_time")]
|
||||
public int RewardLimitTime { get; set; }
|
||||
|
||||
[JsonPropertyName("create_time")]
|
||||
[Key("create_time")]
|
||||
public string CreateTime { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Only present on item/pack-ticket entries (reward_type=4); omit on currency entries.</summary>
|
||||
[JsonPropertyName("item_type")]
|
||||
[Key("item_type")]
|
||||
public int? ItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
[Key("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user