Compare commits
100 Commits
22c01ed11a
...
8e017c9d10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e017c9d10 | ||
|
|
ac2f31103d | ||
|
|
1af56b4ec4 | ||
|
|
1e2e18e828 | ||
|
|
6381e4da51 | ||
|
|
dc19289818 | ||
|
|
668779e8a4 | ||
|
|
f8ca4a0ae9 | ||
|
|
98fb3c5fcd | ||
|
|
2aa0bdefec | ||
|
|
65e0e0fb09 | ||
|
|
09b8c49743 | ||
|
|
f272690a31 | ||
|
|
e245d5b158 | ||
|
|
cc40e2d2e8 | ||
|
|
d550f66481 | ||
|
|
ba49852c42 | ||
|
|
a98b60dd36 | ||
|
|
30a723322c | ||
|
|
2df18425c4 | ||
|
|
721cd738d7 | ||
|
|
5a8ca8853f | ||
|
|
4f5b4c6a6b | ||
|
|
f535642109 | ||
|
|
d49b435e53 | ||
|
|
6e7f0dc4c9 | ||
|
|
5faa5e2445 | ||
|
|
1dbc5fa831 | ||
|
|
b32583ef48 | ||
|
|
50e4989b77 | ||
|
|
1470406e17 | ||
|
|
670e980dc2 | ||
|
|
61ae086332 | ||
|
|
9c9d0fc41f | ||
|
|
d9d29fbfea | ||
|
|
d66d1d8c6e | ||
|
|
517f855112 | ||
|
|
1c386b5ed0 | ||
|
|
0169ec57b4 | ||
|
|
3c36124fa7 | ||
|
|
f7407fe382 | ||
|
|
72c8fe627b | ||
|
|
f9f5b0dfa4 | ||
|
|
8e98180951 | ||
|
|
b78d7d6cbe | ||
|
|
f754ef1ad3 | ||
|
|
06108e4b6f | ||
|
|
2e96001654 | ||
|
|
4965851238 | ||
|
|
d7e5557d61 | ||
|
|
71b3c3e19f | ||
|
|
ed5be80f08 | ||
|
|
9b2696fac5 | ||
|
|
302bf17c31 | ||
|
|
d68a85bbc5 | ||
|
|
ee407befb5 | ||
|
|
5c6b703276 | ||
|
|
fb257a544f | ||
|
|
1f58461326 | ||
|
|
2e021c8b9e | ||
|
|
163299504a | ||
|
|
a3a49077b5 | ||
|
|
092176ea1a | ||
|
|
d560f9ade4 | ||
|
|
0052307686 | ||
|
|
b7ee0cdcf8 | ||
|
|
3bf9ad1c42 | ||
|
|
91c539fb8d | ||
|
|
be19c0ad8d | ||
|
|
b4f6992918 | ||
|
|
7b5edb7c65 | ||
|
|
76aad36e84 | ||
|
|
2d675aa35d | ||
|
|
1e53748ae3 | ||
|
|
6f9976ebad | ||
|
|
bd2eaa9e97 | ||
|
|
363213ccf7 | ||
|
|
66dc0cc657 | ||
|
|
6a507553d1 | ||
|
|
68d783192d | ||
|
|
e792e8d79d | ||
|
|
e0da7f09ca | ||
|
|
75a2fca8bb | ||
|
|
405f49c490 | ||
|
|
7292c44082 | ||
|
|
a8bbc39bfd | ||
|
|
168e347a82 | ||
|
|
739f629996 | ||
|
|
b47ec3b64d | ||
|
|
9e7b7eed27 | ||
|
|
1eaf0d0bc4 | ||
|
|
e1f5b9b6c3 | ||
|
|
c7fb56f95f | ||
|
|
723a97e3af | ||
|
|
e41ceff0be | ||
|
|
66c0b1c951 | ||
|
|
ef1af8259e | ||
|
|
96f1d73e35 | ||
|
|
21adc68e28 | ||
|
|
261ce67cee |
@@ -1016,6 +1016,36 @@ card_id,type,cosmetic_id,quantity
|
||||
718841020,7,718841020,1
|
||||
718841020,8,120018,1
|
||||
718841020,10,3918,1
|
||||
719141010,6,719141011,1
|
||||
719141010,7,400004404,1
|
||||
719141010,7,719141010,1
|
||||
719141010,8,303501,1
|
||||
719141010,10,4201,1
|
||||
719141020,6,719141020,1
|
||||
719141020,7,400004603,1
|
||||
719141020,8,303702,1
|
||||
719141020,10,4401,1
|
||||
719241010,6,719241011,1
|
||||
719241010,7,719241010,1
|
||||
719241010,8,122001,1
|
||||
719241010,10,4002,1
|
||||
719241020,6,719241020,1
|
||||
719241020,7,400004604,1
|
||||
719241020,8,303703,1
|
||||
719241020,10,4402,1
|
||||
719341010,6,719341015,1
|
||||
719341010,7,400004602,1
|
||||
719341010,8,303701,1
|
||||
719341010,10,4413,1
|
||||
719441010,6,719441010,1
|
||||
719441010,7,719441015,1
|
||||
719441010,7,719441016,1
|
||||
719441010,8,122002,1
|
||||
719441010,10,4004,1
|
||||
719641010,6,719641010,1
|
||||
719641010,7,400004605,1
|
||||
719641010,8,303704,1
|
||||
719641010,10,4406,1
|
||||
720541010,6,720541010,1
|
||||
720541010,7,720541010,1
|
||||
721141010,6,721141011,1
|
||||
|
||||
|
14
SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json
Normal file
14
SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "win_count": 0, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 0, "reward_type": 9, "reward_id": 0, "reward_num": 100 },
|
||||
{ "win_count": 1, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 1, "reward_type": 9, "reward_id": 0, "reward_num": 300 },
|
||||
{ "win_count": 2, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 2, "reward_type": 9, "reward_id": 0, "reward_num": 500 },
|
||||
{ "win_count": 3, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 3, "reward_type": 9, "reward_id": 0, "reward_num": 700 },
|
||||
{ "win_count": 4, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 4, "reward_type": 9, "reward_id": 0, "reward_num": 850 },
|
||||
{ "win_count": 5, "reward_type": 4, "reward_id": 80001, "reward_num": 1 },
|
||||
{ "win_count": 5, "reward_type": 9, "reward_id": 0, "reward_num": 1000 }
|
||||
]
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"use_two_pick_premium_card": false,
|
||||
"two_pick_sleeve_id": 3000011
|
||||
"two_pick_sleeve_id": 3000011,
|
||||
"last_card_pack_set_id": 10015,
|
||||
"card_pool_name": "Throwback Rotation",
|
||||
"card_pool_url": "",
|
||||
"announce_id": "",
|
||||
"start_time": "",
|
||||
"end_time": "",
|
||||
"two_pick_type": 0,
|
||||
"strategy_pick_num": 0,
|
||||
"pool_card_set_ids": [10000, 10011, 10012, 10013, 10014, 10015]
|
||||
}
|
||||
|
||||
817202
SVSim.Bootstrap/Data/seeds/pack-draw-card-weights.json
Normal file
817202
SVSim.Bootstrap/Data/seeds/pack-draw-card-weights.json
Normal file
File diff suppressed because it is too large
Load Diff
1955
SVSim.Bootstrap/Data/seeds/pack-draw-config.json
Normal file
1955
SVSim.Bootstrap/Data/seeds/pack-draw-config.json
Normal file
File diff suppressed because it is too large
Load Diff
11840
SVSim.Bootstrap/Data/seeds/pack-draw-slot-rates.json
Normal file
11840
SVSim.Bootstrap/Data/seeds/pack-draw-slot-rates.json
Normal file
File diff suppressed because it is too large
Load Diff
9740
SVSim.Bootstrap/Data/seeds/pack-stubs.json
Normal file
9740
SVSim.Bootstrap/Data/seeds/pack-stubs.json
Normal file
File diff suppressed because it is too large
Load Diff
1346
SVSim.Bootstrap/Data/seeds/story-decks.json
Normal file
1346
SVSim.Bootstrap/Data/seeds/story-decks.json
Normal file
File diff suppressed because it is too large
Load Diff
54
SVSim.Bootstrap/Data/test-fixtures/seeds/pack-stubs.json
Normal file
54
SVSim.Bootstrap/Data/test-fixtures/seeds/pack-stubs.json
Normal file
@@ -0,0 +1,54 @@
|
||||
[
|
||||
{
|
||||
"parent_gacha_id": 10001,
|
||||
"base_pack_id": 10001,
|
||||
"gacha_type": 1,
|
||||
"pack_category": 0,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2016-06-17 00:00:00",
|
||||
"complete_date": "2026-06-30 23:59:59",
|
||||
"sleeve_id": 0,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 0,
|
||||
"override_ui_effect_pack_id": 0,
|
||||
"gacha_detail": "STUB CLC",
|
||||
"is_hide": false,
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{ "gacha_id": 100011, "type_detail": 2, "cost": 200, "card_count": 1 },
|
||||
{ "gacha_id": 100012, "type_detail": 2, "cost": 1800, "card_count": 10 }
|
||||
],
|
||||
"banners": [],
|
||||
"is_enabled": false
|
||||
},
|
||||
{
|
||||
"parent_gacha_id": 95001,
|
||||
"base_pack_id": 95001,
|
||||
"gacha_type": 1,
|
||||
"pack_category": 2,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2016-06-17 00:00:00",
|
||||
"complete_date": "2026-06-30 23:59:59",
|
||||
"sleeve_id": 0,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 0,
|
||||
"override_ui_effect_pack_id": 0,
|
||||
"gacha_detail": "7th Anniv stub",
|
||||
"is_hide": false,
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{ "gacha_id": 950011, "type_detail": 2, "cost": 200, "card_count": 1 },
|
||||
{ "gacha_id": 950012, "type_detail": 2, "cost": 1800, "card_count": 10 }
|
||||
],
|
||||
"banners": [],
|
||||
"is_enabled": false
|
||||
}
|
||||
]
|
||||
@@ -53,7 +53,7 @@ public class AchievementCatalogImporter
|
||||
{
|
||||
Console.WriteLine($"[AchievementCatalogImporter] WARN: {unmappedTypes.Count} types " +
|
||||
$"with no event_type: [{string.Join(", ", unmappedTypes.OrderBy(x => x))}] — " +
|
||||
"add to ACHIEVEMENT_EVENT_MAP in data_dumps/extract/extract-achievements.py");
|
||||
"add to ACHIEVEMENT_EVENT_MAP in data_dumps/scripts/extract-achievements.py");
|
||||
}
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
51
SVSim.Bootstrap/Importers/ArenaTwoPickRewardImporter.cs
Normal file
51
SVSim.Bootstrap/Importers/ArenaTwoPickRewardImporter.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of <see cref="ArenaTwoPickReward"/> rows from
|
||||
/// <c>arena-two-pick-rewards.json</c>. Key = (WinCount, RewardType, RewardId).
|
||||
/// </summary>
|
||||
public class ArenaTwoPickRewardImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var path = Path.Combine(seedDir, "arena-two-pick-rewards.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.WriteLine($"[ArenaTwoPickRewardImporter] missing {path}; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var seeds = SeedLoader.LoadList<ArenaTwoPickRewardSeed>(path);
|
||||
var existing = await context.ArenaTwoPickRewards
|
||||
.ToDictionaryAsync(r => (r.WinCount, r.RewardType, r.RewardId));
|
||||
|
||||
int upserted = 0;
|
||||
foreach (var s in seeds)
|
||||
{
|
||||
if (existing.TryGetValue((s.WinCount, s.RewardType, s.RewardId), out var row))
|
||||
{
|
||||
row.RewardNum = s.RewardNum;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.ArenaTwoPickRewards.Add(new ArenaTwoPickReward
|
||||
{
|
||||
WinCount = s.WinCount,
|
||||
RewardType = s.RewardType,
|
||||
RewardId = s.RewardId,
|
||||
RewardNum = s.RewardNum,
|
||||
});
|
||||
}
|
||||
upserted++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[ArenaTwoPickRewardImporter] upserted={upserted}");
|
||||
return upserted;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public class BattlePassMonthlyMissionImporter
|
||||
{
|
||||
Console.WriteLine($"[BattlePassMonthlyMissionImporter] WARN: {unmapped.Count} rows " +
|
||||
$"with no event_type: [{string.Join(", ", unmapped)}] — add name to " +
|
||||
"BP_MONTHLY_EVENT_MAP in data_dumps/extract/extract-bp-monthly-missions.py");
|
||||
"BP_MONTHLY_EVENT_MAP in data_dumps/scripts/extract-bp-monthly-missions.py");
|
||||
}
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny shared helper for content importers. Capture parsing has moved out of the bootstrap
|
||||
/// project entirely (extractors under <c>data_dumps/extract/</c> emit per-table seed JSON);
|
||||
/// project entirely (extractors under <c>data_dumps/scripts/</c> emit per-table seed JSON);
|
||||
/// only the wire-date normaliser stays here because several seed-driven importers still need
|
||||
/// to canonicalise prod-shaped timestamp strings.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the item catalog from <c>seeds/items.json</c>. Source is the client's
|
||||
/// <c>item_master.csv</c> + <c>itemtext.json</c> (extracted via
|
||||
/// <c>data_dumps/extract/extract-items.py</c>). Rows missing from the seed are LEFT INTACT.
|
||||
/// <c>data_dumps/scripts/extract-items.py</c>). Rows missing from the seed are LEFT INTACT.
|
||||
/// </summary>
|
||||
public class ItemImporter
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the item-purchase catalog from <c>seeds/item-purchase.json</c>.
|
||||
/// Source is the wire <c>/item_purchase/info</c> response, extracted via
|
||||
/// <c>data_dumps/extract/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||
/// <c>data_dumps/scripts/extract-item-purchase.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||
/// </summary>
|
||||
public class ItemPurchaseImporter
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// Idempotent upsert of the leader-skin-shop catalog from <c>seeds/leader-skin-shop.json</c>.
|
||||
/// Mirror of <see cref="SleeveShopImporter"/>. Source is the wire
|
||||
/// <c>/leader_skin/products</c> response, extracted via
|
||||
/// <c>data_dumps/extract/extract-leader-skin-shop.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||
/// <c>data_dumps/scripts/extract-leader-skin-shop.py</c>. Rows missing from the seed are LEFT INTACT.
|
||||
/// </summary>
|
||||
public class LeaderSkinShopImporter
|
||||
{
|
||||
|
||||
@@ -50,7 +50,7 @@ public class MissionCatalogImporter
|
||||
{
|
||||
Console.WriteLine($"[MissionCatalogImporter] WARN: {unmapped.Count} mission_ids with " +
|
||||
$"no event_type: [{string.Join(", ", unmapped)}] — add to MISSION_EVENT_MAP " +
|
||||
"in data_dumps/extract/extract-missions.py and re-run the extractor");
|
||||
"in data_dumps/scripts/extract-missions.py and re-run the extractor");
|
||||
}
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
102
SVSim.Bootstrap/Importers/PackDrawTableImporter.cs
Normal file
102
SVSim.Bootstrap/Importers/PackDrawTableImporter.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the per-pack draw table from
|
||||
/// <c>seeds/pack-draw-config.json</c>, <c>pack-draw-slot-rates.json</c>, and
|
||||
/// <c>pack-draw-card-weights.json</c>. Replaces wholesale per pack (config keyed on
|
||||
/// pack_id; slot rates / card weights wiped and reinserted) — the upstream data is
|
||||
/// post-shutdown closed, so we do not preserve hand-edits on these tables.
|
||||
/// </summary>
|
||||
public class PackDrawTableImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var configs = SeedLoader.LoadList<PackDrawConfigSeed>(Path.Combine(seedDir, "pack-draw-config.json"));
|
||||
var slotRates = SeedLoader.LoadList<PackDrawSlotRateSeed>(Path.Combine(seedDir, "pack-draw-slot-rates.json"));
|
||||
var cardWeights = SeedLoader.LoadList<PackDrawCardWeightSeed>(Path.Combine(seedDir, "pack-draw-card-weights.json"));
|
||||
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[PackDrawTableImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var seedPackIds = configs.Select(c => c.PackId).ToHashSet();
|
||||
|
||||
// Full-replace strategy: wipe rows for any pack in the seed, then reinsert.
|
||||
await context.PackDrawCardWeights
|
||||
.Where(w => seedPackIds.Contains(w.PackId))
|
||||
.ExecuteDeleteAsync();
|
||||
await context.PackDrawSlotRates
|
||||
.Where(s => seedPackIds.Contains(s.PackId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
var existingConfigs = await context.PackDrawConfigs
|
||||
.Where(c => seedPackIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
foreach (var s in configs)
|
||||
{
|
||||
var row = existingConfigs.TryGetValue(s.PackId, out var ex)
|
||||
? ex : new PackDrawConfigEntry { Id = s.PackId };
|
||||
row.AnimationRatePct = s.AnimationRatePct;
|
||||
row.HasBonusSlot = s.HasBonusSlot;
|
||||
row.SpecialKind = s.SpecialKind;
|
||||
row.ShortCode = s.ShortCode;
|
||||
if (ex is null) context.PackDrawConfigs.Add(row);
|
||||
}
|
||||
|
||||
foreach (var s in slotRates)
|
||||
{
|
||||
context.PackDrawSlotRates.Add(new PackDrawSlotRateEntry
|
||||
{
|
||||
PackId = s.PackId,
|
||||
Slot = ParseSlot(s.Slot),
|
||||
Tier = ParseTier(s.Tier),
|
||||
RatePct = s.RatePct,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var s in cardWeights)
|
||||
{
|
||||
context.PackDrawCardWeights.Add(new PackDrawCardWeightEntry
|
||||
{
|
||||
PackId = s.PackId,
|
||||
Slot = ParseSlot(s.Slot),
|
||||
Tier = ParseTier(s.Tier),
|
||||
CardId = s.CardId,
|
||||
RatePct = s.RatePct,
|
||||
IsLeader = s.IsLeader,
|
||||
IsAltArt = s.IsAltArt,
|
||||
});
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackDrawTableImporter] {configs.Count} configs / {slotRates.Count} slot rates / {cardWeights.Count} card weights");
|
||||
return configs.Count;
|
||||
}
|
||||
|
||||
private static DrawSlot ParseSlot(string s) => s switch
|
||||
{
|
||||
"general" => DrawSlot.General,
|
||||
"eighth" => DrawSlot.Eighth,
|
||||
"bonus" => DrawSlot.Bonus,
|
||||
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown slot \"{s}\""),
|
||||
};
|
||||
|
||||
private static DrawTier ParseTier(string s) => s switch
|
||||
{
|
||||
"bronze" => DrawTier.Bronze,
|
||||
"silver" => DrawTier.Silver,
|
||||
"gold" => DrawTier.Gold,
|
||||
"legendary" => DrawTier.Legendary,
|
||||
"special" => DrawTier.Special,
|
||||
_ => throw new InvalidDataException($"PackDrawTableImporter: unknown tier \"{s}\""),
|
||||
};
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public class PackImporter
|
||||
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
|
||||
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
|
||||
};
|
||||
pack.IsEnabled = s.IsEnabled;
|
||||
|
||||
// Owned collections -- clear and rehydrate (matches the previous wholesale-replace semantics).
|
||||
pack.ChildGachas.Clear();
|
||||
@@ -101,7 +102,75 @@ public class PackImporter
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
Console.WriteLine($"[PackImporter] capture: +{created}/~{updated}");
|
||||
|
||||
// Second pass: synthesized stubs from pack-stubs.json. Skip any pack_id that already
|
||||
// exists from the live-capture pass (capture wins on conflict).
|
||||
var stubs = SeedLoader.LoadList<PackSeed>(Path.Combine(seedDir, "pack-stubs.json"));
|
||||
int stubsAdded = 0;
|
||||
foreach (var s in stubs)
|
||||
{
|
||||
if (s.ParentGachaId == 0) continue;
|
||||
if (existing.ContainsKey(s.ParentGachaId)) continue;
|
||||
|
||||
var pack = new PackConfigEntry
|
||||
{
|
||||
Id = s.ParentGachaId,
|
||||
BasePackId = s.BasePackId,
|
||||
GachaType = s.GachaType,
|
||||
PackCategory = (PackCategory)s.PackCategory,
|
||||
PosterType = s.PosterType,
|
||||
CommenceDate = ParseWireDateTime(s.CommenceDate),
|
||||
CompleteDate = ParseWireDateTime(s.CompleteDate),
|
||||
SleeveId = s.SleeveId,
|
||||
SpecialSleeveId = s.SpecialSleeveId,
|
||||
OverrideDrawEffectPackId = s.OverrideDrawEffectPackId,
|
||||
OverrideUiEffectPackId = s.OverrideUiEffectPackId,
|
||||
GachaDetail = s.GachaDetail,
|
||||
IsHide = s.IsHide,
|
||||
IsNew = s.IsNew,
|
||||
IsPreRelease = s.IsPreRelease,
|
||||
OpenCountLimit = s.OpenCountLimit,
|
||||
SalesPeriodTime = string.IsNullOrEmpty(s.SalesPeriodTime) ? null : ParseWireDateTime(s.SalesPeriodTime),
|
||||
GachaPointConfig = s.GachaPoint is null ? null : new PackGachaPointConfig
|
||||
{
|
||||
ExchangeablePoint = s.GachaPoint.ExchangeablePoint,
|
||||
IncreaseGachaPoint = s.GachaPoint.IncreaseGachaPoint,
|
||||
},
|
||||
IsEnabled = s.IsEnabled,
|
||||
};
|
||||
foreach (var c in s.ChildGachas)
|
||||
{
|
||||
pack.ChildGachas.Add(new PackChildGachaEntry
|
||||
{
|
||||
GachaId = c.GachaId,
|
||||
TypeDetail = c.TypeDetail,
|
||||
Cost = c.Cost,
|
||||
CardCount = c.CardCount,
|
||||
ItemId = c.ItemId,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount,
|
||||
FreeGachaCampaignId = c.FreeGachaCampaignId,
|
||||
CampaignName = c.CampaignName,
|
||||
});
|
||||
}
|
||||
foreach (var b in s.Banners)
|
||||
{
|
||||
pack.Banners.Add(new PackBannerEntry
|
||||
{
|
||||
BannerName = b.BannerName,
|
||||
DialogTitle = b.DialogTitle,
|
||||
});
|
||||
}
|
||||
context.Packs.Add(pack);
|
||||
existing[s.ParentGachaId] = pack;
|
||||
stubsAdded++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[PackImporter] stubs: +{stubsAdded}");
|
||||
|
||||
return created + updated + stubsAdded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,15 @@ public class RotationConfigImporter
|
||||
{
|
||||
c.UseTwoPickPremiumCard = cc.UseTwoPickPremiumCard;
|
||||
c.TwoPickSleeveId = cc.TwoPickSleeveId;
|
||||
c.LastCardPackSetId = cc.LastCardPackSetId;
|
||||
c.CardPoolName = cc.CardPoolName;
|
||||
c.CardPoolUrl = cc.CardPoolUrl;
|
||||
c.AnnounceId = cc.AnnounceId;
|
||||
c.StartTime = cc.StartTime;
|
||||
c.EndTime = cc.EndTime;
|
||||
c.TwoPickType = cc.TwoPickType;
|
||||
c.StrategyPickNum = cc.StrategyPickNum;
|
||||
c.PoolCardSetIds = cc.PoolCardSetIds ?? new List<int>();
|
||||
});
|
||||
touched++;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a JSON seed file under <c>SVSim.Bootstrap/Data/seeds/</c>. Replaces ImporterBase.LoadCapture.
|
||||
/// Files are produced by extractors in <c>data_dumps/extract/</c>; the bootstrap project does not
|
||||
/// Files are produced by extractors in <c>data_dumps/scripts/</c>; the bootstrap project does not
|
||||
/// transform wire formats. Missing files are non-fatal (returns empty/null) — caller decides.
|
||||
/// </summary>
|
||||
public static class SeedLoader
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the sleeve-shop catalog from <c>seeds/sleeve-shop.json</c>.
|
||||
/// Source is the wire <c>/sleeve/info</c> response, extracted via
|
||||
/// <c>data_dumps/extract/extract-sleeve-shop.py</c>. Mirror of the BuildDeck importer pattern.
|
||||
/// <c>data_dumps/scripts/extract-sleeve-shop.py</c>. Mirror of the BuildDeck importer pattern.
|
||||
/// Rows missing from the seed are LEFT INTACT (so manual test fixtures survive re-runs).
|
||||
/// </summary>
|
||||
public class SleeveShopImporter
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the spot card exchange catalog from <c>seeds/spot-card-exchange.json</c>.
|
||||
/// Source is the wire <c>/spot_card_exchange/top</c> response, extracted via
|
||||
/// <c>data_dumps/extract/extract-spot-card-exchange.py</c>. Rows missing from the seed are
|
||||
/// <c>data_dumps/scripts/extract-spot-card-exchange.py</c>. Rows missing from the seed are
|
||||
/// LEFT INTACT.
|
||||
/// </summary>
|
||||
public class SpotCardExchangeImporter
|
||||
|
||||
52
SVSim.Bootstrap/Importers/StoryDeckImporter.cs
Normal file
52
SVSim.Bootstrap/Importers/StoryDeckImporter.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of story-deck presentation rows from <c>seeds/story-decks.json</c>.
|
||||
/// Card lists are NOT imported here — they belong to BuildDeckProductEntry (deck_no == product_id),
|
||||
/// so this importer should run AFTER BuildDeckImporter.ImportPackageAsync. Rows missing from the
|
||||
/// seed are left intact.
|
||||
/// </summary>
|
||||
public class StoryDeckImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<StoryDeckSeed>(Path.Combine(seedDir, "story-decks.json"));
|
||||
if (seed.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[StoryDeckImporter] No seed rows; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var existing = await context.StoryDecks.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.DeckNo == 0) continue;
|
||||
var entry = existing.TryGetValue(s.DeckNo, out var ex) ? ex : new StoryDeckEntry { DeckNo = s.DeckNo };
|
||||
entry.Kind = string.Equals(s.Kind, "trial", StringComparison.OrdinalIgnoreCase)
|
||||
? StoryDeckKind.Trial : StoryDeckKind.Build;
|
||||
entry.ClassId = s.ClassId;
|
||||
entry.DeckName = s.DeckName;
|
||||
entry.SleeveId = s.SleeveId;
|
||||
entry.LeaderSkinId = s.LeaderSkinId;
|
||||
entry.IsRecommend = s.IsRecommend;
|
||||
entry.OrderNum = s.OrderNum;
|
||||
entry.EntryNo = s.EntryNo;
|
||||
entry.DeckFormat = s.DeckFormat;
|
||||
|
||||
if (ex is null) { context.StoryDecks.Add(entry); existing[s.DeckNo] = entry; created++; }
|
||||
else updated++;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[StoryDeckImporter] +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
}
|
||||
11
SVSim.Bootstrap/Models/Seed/ArenaTwoPickRewardSeed.cs
Normal file
11
SVSim.Bootstrap/Models/Seed/ArenaTwoPickRewardSeed.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public class ArenaTwoPickRewardSeed
|
||||
{
|
||||
[JsonPropertyName("win_count")] public int WinCount { get; set; }
|
||||
[JsonPropertyName("reward_type")] public int RewardType { get; set; }
|
||||
[JsonPropertyName("reward_id")] public long RewardId { get; set; }
|
||||
[JsonPropertyName("reward_num")] public int RewardNum { get; set; }
|
||||
}
|
||||
14
SVSim.Bootstrap/Models/Seed/PackDrawCardWeightSeed.cs
Normal file
14
SVSim.Bootstrap/Models/Seed/PackDrawCardWeightSeed.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawCardWeightSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
|
||||
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
[JsonPropertyName("rate_pct")] public double? RatePct { get; set; }
|
||||
[JsonPropertyName("is_leader")] public bool IsLeader { get; set; }
|
||||
[JsonPropertyName("is_alt_art")] public bool IsAltArt { get; set; }
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/PackDrawConfigSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/PackDrawConfigSeed.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawConfigSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("short_code")] public string? ShortCode { get; set; }
|
||||
[JsonPropertyName("animation_rate_pct")] public double AnimationRatePct { get; set; }
|
||||
[JsonPropertyName("has_bonus_slot")] public bool HasBonusSlot { get; set; }
|
||||
[JsonPropertyName("special_kind")] public string? SpecialKind { get; set; }
|
||||
}
|
||||
11
SVSim.Bootstrap/Models/Seed/PackDrawSlotRateSeed.cs
Normal file
11
SVSim.Bootstrap/Models/Seed/PackDrawSlotRateSeed.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class PackDrawSlotRateSeed
|
||||
{
|
||||
[JsonPropertyName("pack_id")] public int PackId { get; set; }
|
||||
[JsonPropertyName("slot")] public string Slot { get; set; } = "general";
|
||||
[JsonPropertyName("tier")] public string Tier { get; set; } = "bronze";
|
||||
[JsonPropertyName("rate_pct")] public double RatePct { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class PackSeed
|
||||
[JsonPropertyName("gacha_point")] public PackGachaPointSeed? GachaPoint { get; set; }
|
||||
[JsonPropertyName("child_gachas")] public List<PackChildGachaSeed> ChildGachas { get; set; } = new();
|
||||
[JsonPropertyName("banners")] public List<PackBannerSeed> Banners { get; set; } = new();
|
||||
[JsonPropertyName("is_enabled")] public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class PackGachaPointSeed
|
||||
|
||||
@@ -21,6 +21,16 @@ public sealed class ChallengeConfigSeed
|
||||
{
|
||||
[JsonPropertyName("use_two_pick_premium_card")] public bool UseTwoPickPremiumCard { get; set; }
|
||||
[JsonPropertyName("two_pick_sleeve_id")] public long TwoPickSleeveId { get; set; }
|
||||
|
||||
[JsonPropertyName("last_card_pack_set_id")] public int LastCardPackSetId { get; set; }
|
||||
[JsonPropertyName("card_pool_name")] public string CardPoolName { get; set; } = "";
|
||||
[JsonPropertyName("card_pool_url")] public string CardPoolUrl { get; set; } = "";
|
||||
[JsonPropertyName("announce_id")] public string AnnounceId { get; set; } = "";
|
||||
[JsonPropertyName("start_time")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("two_pick_type")] public int TwoPickType { get; set; } = 0;
|
||||
[JsonPropertyName("strategy_pick_num")] public int StrategyPickNum { get; set; } = 0;
|
||||
[JsonPropertyName("pool_card_set_ids")] public List<int> PoolCardSetIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
17
SVSim.Bootstrap/Models/Seed/StoryDeckSeed.cs
Normal file
17
SVSim.Bootstrap/Models/Seed/StoryDeckSeed.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
public sealed class StoryDeckSeed
|
||||
{
|
||||
[JsonPropertyName("deck_no")] public int DeckNo { get; set; }
|
||||
[JsonPropertyName("kind")] public string Kind { get; set; } = "build";
|
||||
[JsonPropertyName("class_id")] public int ClassId { get; set; }
|
||||
[JsonPropertyName("deck_name")] public string DeckName { get; set; } = "";
|
||||
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
|
||||
[JsonPropertyName("leader_skin_id")] public int LeaderSkinId { get; set; }
|
||||
[JsonPropertyName("is_recommend")] public int IsRecommend { get; set; }
|
||||
[JsonPropertyName("order_num")] public int OrderNum { get; set; }
|
||||
[JsonPropertyName("entry_no")] public int EntryNo { get; set; }
|
||||
[JsonPropertyName("deck_format")] public int? DeckFormat { get; set; }
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public static class Program
|
||||
if (!opts.SkipGlobals)
|
||||
{
|
||||
// Per-domain seed pipeline. Each importer reads a per-table JSON seed file under
|
||||
// SVSim.Bootstrap/Data/seeds/ produced by an extractor in data_dumps/extract/.
|
||||
// SVSim.Bootstrap/Data/seeds/ produced by an extractor in data_dumps/scripts/.
|
||||
//
|
||||
// RotationConfigImporter writes the Rotation GameConfig section that RotationFlagUpdater
|
||||
// reads; CardImporter ran earlier in the !SkipCards block so CardSets are populated.
|
||||
@@ -84,6 +84,7 @@ public static class Program
|
||||
await new MyRotationImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ArenaTwoPickRewardImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new BattlePassRewardImporter().ImportAsync(context, opts.SeedDir);
|
||||
@@ -116,6 +117,7 @@ public static class Program
|
||||
|
||||
await new DefaultDeckImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PackImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PackDrawTableImporter().ImportAsync(context, opts.SeedDir);
|
||||
|
||||
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
|
||||
// series CSV (FK on products → series) and before package CSV (so the catalog-side
|
||||
@@ -124,6 +126,7 @@ public static class Program
|
||||
await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir);
|
||||
await buildDeck.ImportCatalogAsync(context, opts.SeedDir);
|
||||
await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir);
|
||||
await new StoryDeckImporter().ImportAsync(context, opts.SeedDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -227,7 +230,7 @@ public static class Program
|
||||
" --story-data-dir <dir> Override story data directory (default: shipped Data/story)\n" +
|
||||
" --skip-story Skip story import (worlds/sections/chapters/sbs)\n" +
|
||||
"\n" +
|
||||
"Capture-derived seeds are produced by extractors under data_dumps/extract/* and\n" +
|
||||
"Capture-derived seeds are produced by extractors under data_dumps/scripts/* and\n" +
|
||||
"checked into SVSim.Bootstrap/Data/seeds/. The bootstrap project never parses wire\n" +
|
||||
"captures directly — refresh seeds by re-running the relevant extractor.\n" +
|
||||
"\n" +
|
||||
|
||||
8
SVSim.Database/Enums/DrawSlot.cs
Normal file
8
SVSim.Database/Enums/DrawSlot.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
public enum DrawSlot
|
||||
{
|
||||
General = 0,
|
||||
Eighth = 1,
|
||||
Bonus = 2,
|
||||
}
|
||||
16
SVSim.Database/Enums/DrawTier.cs
Normal file
16
SVSim.Database/Enums/DrawTier.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Per-draw page tier the slot rolls into. Distinct from card-master <see cref="Rarity"/>:
|
||||
/// for the four base values they line up, but <c>Special</c> covers the per-pack
|
||||
/// "Leader Card" / "Limited-Time Leader" tiers — its cards are typically Rarity.Legendary
|
||||
/// with the IsLeader printing flag set.
|
||||
/// </summary>
|
||||
public enum DrawTier
|
||||
{
|
||||
Bronze = 0,
|
||||
Silver = 1,
|
||||
Gold = 2,
|
||||
Legendary = 3,
|
||||
Special = 4,
|
||||
}
|
||||
11
SVSim.Database/Enums/StoryDeckKind.cs
Normal file
11
SVSim.Database/Enums/StoryDeckKind.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Which story deck-select group a prebuilt deck belongs to. Build = the named story decks
|
||||
/// (build_deck_list); Trial = archetype trial decks (trial_deck_list). Stored as int.
|
||||
/// </summary>
|
||||
public enum StoryDeckKind
|
||||
{
|
||||
Build = 0,
|
||||
Trial = 1,
|
||||
}
|
||||
3776
SVSim.Database/Migrations/20260529024929_AddGachaPointExchange.Designer.cs
generated
Normal file
3776
SVSim.Database/Migrations/20260529024929_AddGachaPointExchange.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGachaPointExchange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerGachaPointBalance",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
Points = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerGachaPointBalance", x => new { x.ViewerId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerGachaPointBalance_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerGachaPointReceived",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ReceivedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerGachaPointReceived", x => new { x.ViewerId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerGachaPointReceived_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerGachaPointBalance_ViewerId_PackId",
|
||||
table: "ViewerGachaPointBalance",
|
||||
columns: new[] { "ViewerId", "PackId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerGachaPointReceived_ViewerId_PackId_CardId",
|
||||
table: "ViewerGachaPointReceived",
|
||||
columns: new[] { "ViewerId", "PackId", "CardId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerGachaPointBalance");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerGachaPointReceived");
|
||||
}
|
||||
}
|
||||
}
|
||||
3823
SVSim.Database/Migrations/20260529142631_AddStoryDeck.Designer.cs
generated
Normal file
3823
SVSim.Database/Migrations/20260529142631_AddStoryDeck.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
SVSim.Database/Migrations/20260529142631_AddStoryDeck.cs
Normal file
45
SVSim.Database/Migrations/20260529142631_AddStoryDeck.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStoryDeck : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryDecks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckNo = table.Column<int>(type: "integer", nullable: false),
|
||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckName = table.Column<string>(type: "text", nullable: false),
|
||||
SleeveId = table.Column<int>(type: "integer", nullable: false),
|
||||
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsRecommend = table.Column<int>(type: "integer", nullable: false),
|
||||
OrderNum = table.Column<int>(type: "integer", nullable: false),
|
||||
EntryNo = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckFormat = table.Column<int>(type: "integer", nullable: true),
|
||||
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_StoryDecks", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryDecks");
|
||||
}
|
||||
}
|
||||
}
|
||||
3930
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.Designer.cs
generated
Normal file
3930
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
107
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.cs
Normal file
107
SVSim.Database/Migrations/20260531013928_AddPackDrawTable.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPackDrawTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsEnabled",
|
||||
table: "Packs",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawCardWeights",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
Tier = table.Column<int>(type: "integer", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RatePct = table.Column<double>(type: "double precision", nullable: true),
|
||||
IsLeader = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsAltArt = 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_PackDrawCardWeights", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawConfigs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
AnimationRatePct = table.Column<double>(type: "double precision", nullable: false),
|
||||
HasBonusSlot = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SpecialKind = table.Column<string>(type: "text", nullable: true),
|
||||
ShortCode = table.Column<string>(type: "text", nullable: true),
|
||||
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_PackDrawConfigs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackDrawSlotRates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
Slot = table.Column<int>(type: "integer", nullable: false),
|
||||
Tier = table.Column<int>(type: "integer", nullable: false),
|
||||
RatePct = table.Column<double>(type: "double precision", 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_PackDrawSlotRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PackDrawCardWeights_PackId_Slot_Tier",
|
||||
table: "PackDrawCardWeights",
|
||||
columns: new[] { "PackId", "Slot", "Tier" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PackDrawSlotRates_PackId_Slot_Tier",
|
||||
table: "PackDrawSlotRates",
|
||||
columns: new[] { "PackId", "Slot", "Tier" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawCardWeights");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawConfigs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackDrawSlotRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsEnabled",
|
||||
table: "Packs");
|
||||
}
|
||||
}
|
||||
}
|
||||
4037
SVSim.Database/Migrations/20260531141959_AddArenaTwoPick.Designer.cs
generated
Normal file
4037
SVSim.Database/Migrations/20260531141959_AddArenaTwoPick.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
90
SVSim.Database/Migrations/20260531141959_AddArenaTwoPick.cs
Normal file
90
SVSim.Database/Migrations/20260531141959_AddArenaTwoPick.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddArenaTwoPick : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ArenaTwoPickRewards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
WinCount = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardNum = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ArenaTwoPickRewards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerArenaTwoPickRuns",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
EntryId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardScheduleId = table.Column<int>(type: "integer", nullable: false),
|
||||
ChallengeId = table.Column<int>(type: "integer", nullable: false),
|
||||
MaxBattleCount = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
LeaderSkinId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CandidateClassIdsJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
SelectTurn = table.Column<int>(type: "integer", nullable: false),
|
||||
IsSelectCompleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
SelectedCardIdsJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
PendingPickSetsJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
NextCandidateId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ResultListJson = table.Column<string>(type: "jsonb", nullable: false),
|
||||
WinCount = table.Column<int>(type: "integer", nullable: false),
|
||||
LossCount = table.Column<int>(type: "integer", nullable: false),
|
||||
IsRetire = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerArenaTwoPickRuns", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount",
|
||||
table: "ArenaTwoPickRewards",
|
||||
column: "WinCount");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ArenaTwoPickRewards_WinCount_RewardType_RewardId",
|
||||
table: "ArenaTwoPickRewards",
|
||||
columns: new[] { "WinCount", "RewardType", "RewardId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerArenaTwoPickRuns_ViewerId",
|
||||
table: "ViewerArenaTwoPickRuns",
|
||||
column: "ViewerId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ArenaTwoPickRewards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerArenaTwoPickRuns");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,6 +450,36 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ArenaSeasons");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ArenaTwoPickReward", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("RewardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("RewardNum")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WinCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WinCount");
|
||||
|
||||
b.HasIndex("WinCount", "RewardType", "RewardId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArenaTwoPickRewards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.AvatarAbilityEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1500,6 +1530,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int>("GachaType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsHide")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -1538,6 +1571,110 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("Packs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawCardWeightEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsAltArt")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsLeader")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("RatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Tier")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PackId", "Slot", "Tier");
|
||||
|
||||
b.ToTable("PackDrawCardWeights");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawConfigEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("AnimationRatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("HasBonusSlot")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ShortCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SpecialKind")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PackDrawConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PackDrawSlotRateEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double>("RatePct")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int>("Slot")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Tier")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PackId", "Slot", "Tier")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PackDrawSlotRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2240,6 +2377,53 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("SpotCardExchangeCatalog");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.StoryDeckEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("DateCreated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DateUpdated")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DeckFormat")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("DeckName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("DeckNo")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EntryNo")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("IsRecommend")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("LeaderSkinId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SleeveId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("StoryDecks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -2328,6 +2512,83 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("ViewerAchievements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerArenaTwoPickRun", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CandidateClassIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("ChallengeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("EntryId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsRetire")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSelectCompleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<long>("LeaderSkinId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("LossCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxBattleCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("NextCandidateId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("PendingPickSetsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("ResultListJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RewardScheduleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("SelectTurn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SelectedCardIdsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("WinCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ViewerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ViewerArenaTwoPickRuns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ViewerBattlePassClaimEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -3476,6 +3737,65 @@ namespace SVSim.Database.Migrations
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointBalance", "GachaPointBalances", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Points")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("ViewerId", "Id");
|
||||
|
||||
b1.HasIndex("ViewerId", "PackId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("ViewerGachaPointBalance");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerGachaPointReceived", "GachaPointReceived", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<long>("CardId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("PackId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<DateTime>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b1.HasKey("ViewerId", "Id");
|
||||
|
||||
b1.HasIndex("ViewerId", "PackId", "CardId")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("ViewerGachaPointReceived");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsOne("SVSim.Database.Models.ViewerInfo", "Info", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
@@ -3593,6 +3913,10 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("Currency")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GachaPointBalances");
|
||||
|
||||
b.Navigation("GachaPointReceived");
|
||||
|
||||
b.Navigation("Info")
|
||||
.IsRequired();
|
||||
|
||||
|
||||
29
SVSim.Database/Models/ArenaTwoPickReward.cs
Normal file
29
SVSim.Database/Models/ArenaTwoPickReward.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
// SVSim.Database/Models/ArenaTwoPickReward.cs
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row of the Take Two run-end reward table. Multiple rows per <see cref="WinCount"/>
|
||||
/// (e.g. 1 ticket + N rupies = 2 rows). Seeded by <c>ArenaTwoPickRewardImporter</c> from
|
||||
/// <c>SVSim.Bootstrap/Data/seeds/arena-two-pick-rewards.json</c>.
|
||||
/// </summary>
|
||||
[Index(nameof(WinCount))]
|
||||
[Index(nameof(WinCount), nameof(RewardType), nameof(RewardId), IsUnique = true)]
|
||||
public class ArenaTwoPickReward
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>0..MaxWins. Run ends at LossCount==2 or WinCount==MAX(WinCount).</summary>
|
||||
public int WinCount { get; set; }
|
||||
|
||||
/// <summary><see cref="UserGoodsType"/> on the wire (e.g. Item=4, Rupy=9).</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Item id for Item; 0 for currencies.</summary>
|
||||
public long RewardId { get; set; }
|
||||
|
||||
/// <summary>Count (e.g. ticket quantity or rupy amount).</summary>
|
||||
public int RewardNum { get; set; }
|
||||
}
|
||||
16
SVSim.Database/Models/CandidatePair.cs
Normal file
16
SVSim.Database/Models/CandidatePair.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One of the 2 pick-sets offered to the player on the current draft turn. Persisted as
|
||||
/// part of <see cref="ViewerArenaTwoPickRun.PendingPickSetsJson"/>. <see cref="Id"/> is the
|
||||
/// monotonic counter the client sends back as <c>selected_id</c> on /card_choose.
|
||||
/// </summary>
|
||||
public class CandidatePair
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int Turn { get; set; }
|
||||
public int SetNum { get; set; }
|
||||
public long CardId1 { get; set; }
|
||||
public long CardId2 { get; set; }
|
||||
public bool IsSelected { get; set; }
|
||||
}
|
||||
37
SVSim.Database/Models/Config/ArenaTwoPickConfig.cs
Normal file
37
SVSim.Database/Models/Config/ArenaTwoPickConfig.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Take Two run mechanics: rarity weights, class/neutral mix, per-battle XP, season ids,
|
||||
/// allowed-class allow-list. The pool's set scoping lives on <see cref="ChallengeConfig"/>;
|
||||
/// this section is purely mechanics + the reward-schedule/challenge ids stamped on each run.
|
||||
/// </summary>
|
||||
[ConfigSection("ArenaTwoPick")]
|
||||
public class ArenaTwoPickConfig
|
||||
{
|
||||
public int RewardScheduleId { get; set; } = 1;
|
||||
public int ChallengeId { get; set; } = 1;
|
||||
public int ClassXpPerBattle { get; set; } = 100;
|
||||
public int SpotPointsPerBattle { get; set; } = 10;
|
||||
|
||||
public double LegendaryRate { get; set; } = 0.06;
|
||||
public double GoldRate { get; set; } = 0.17;
|
||||
public double SilverRate { get; set; } = 0.33;
|
||||
public double BronzeRate { get; set; } = 0.44;
|
||||
|
||||
public double NeutralMixRate { get; set; } = 0.25;
|
||||
|
||||
/// <summary>TK2 entry ticket — item id 1 (challenge ticket). Distinct from the run-end
|
||||
/// REWARD ticket id (80001, throwback pack ticket).</summary>
|
||||
public int TicketItemId { get; set; } = 1;
|
||||
|
||||
public int TicketCost { get; set; } = 1;
|
||||
public int CrystalCost { get; set; } = 150;
|
||||
public int RupyCost { get; set; } = 150;
|
||||
|
||||
public List<int> AllowedClassIds { get; set; } = new();
|
||||
|
||||
public static ArenaTwoPickConfig ShippedDefaults() => new()
|
||||
{
|
||||
AllowedClassIds = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8 },
|
||||
};
|
||||
}
|
||||
@@ -6,5 +6,19 @@ public class ChallengeConfig
|
||||
public bool UseTwoPickPremiumCard { get; set; }
|
||||
public long TwoPickSleeveId { get; set; }
|
||||
|
||||
// Wire-mirrored fields from format_info (ChallengeData.cs parser)
|
||||
public int LastCardPackSetId { get; set; }
|
||||
public string CardPoolName { get; set; } = "";
|
||||
public string CardPoolUrl { get; set; } = "";
|
||||
public string AnnounceId { get; set; } = "";
|
||||
public string StartTime { get; set; } = "";
|
||||
public string EndTime { get; set; } = "";
|
||||
public int TwoPickType { get; set; } = 0;
|
||||
public int StrategyPickNum { get; set; } = 0;
|
||||
|
||||
// Server-internal: which sets the TK2 pool draws from. Empty falls back to
|
||||
// RotationConfig.RotationCardSetIds at runtime in the card-pool service.
|
||||
public List<int> PoolCardSetIds { get; set; } = new();
|
||||
|
||||
public static ChallengeConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
17
SVSim.Database/Models/Config/FreeplayConfig.cs
Normal file
17
SVSim.Database/Models/Config/FreeplayConfig.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Global "freeplay" toggle. When <see cref="Enabled"/>, every viewer is treated (in logic,
|
||||
/// never in the DB) as owning all cards (<see cref="CardCopies"/> each), all cosmetics, and
|
||||
/// <see cref="CurrencyAmount"/> of Crystal/Rupee/Red-Ether. See
|
||||
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
|
||||
/// </summary>
|
||||
[ConfigSection("Freeplay")]
|
||||
public class FreeplayConfig
|
||||
{
|
||||
public bool Enabled { get; set; } = false;
|
||||
public ulong CurrencyAmount { get; set; } = 99999;
|
||||
public int CardCopies { get; set; } = 3;
|
||||
|
||||
public static FreeplayConfig ShippedDefaults() => new();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace SVSim.Database.Models.Config;
|
||||
/// <see cref="ShippedDefaults"/>, not in the initialiser — see PerSlot docstring.
|
||||
/// </summary>
|
||||
[ConfigSection("PackRates")]
|
||||
[Obsolete("PackRateConfig is no longer consulted by PackOpenService — per-pack rates come from PackDrawTable. Retire once v1 stabilizes.")]
|
||||
public class PackRateConfig
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -13,7 +13,7 @@ public class ResourceConfig
|
||||
/// <c>PlayerPrefs["RES_VER"]</c> and uses it as the version path component for asset
|
||||
/// manifest lookups: <c>https://<cdn>/dl/Manifest/<RES_VER>/<lang>/<Platform>/</c>.
|
||||
/// <para>
|
||||
/// Default value is the prod-captured version from <c>data_dumps/traffic_prod_tutorial.ndjson</c>
|
||||
/// Default value is the prod-captured version from <c>data_dumps/captures/traffic_prod_tutorial.ndjson</c>
|
||||
/// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets
|
||||
/// ahead of June 2026), update via DB <c>GameConfigs</c> row, appsettings.json, or this
|
||||
/// shipped default; no code change needed.
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Item master row. Mirrors the client's <c>item_master.csv</c> + <c>itemtext.json</c>
|
||||
/// (under <c>data_dumps/client_master_csv/</c>): <see cref="Type"/> matches the client-side
|
||||
/// (under <c>data_dumps/client-assets/</c>): <see cref="Type"/> matches the client-side
|
||||
/// item_type enum (1 = challenge ticket, 2 = card-pack ticket, 3 = premium orb,
|
||||
/// 4 = colosseum ticket, 5 = orb piece, 6 = skin/event ticket, 7 = other);
|
||||
/// <see cref="ThumbnailPath"/> is the client-resolved sprite key.
|
||||
|
||||
@@ -33,6 +33,13 @@ public class PackConfigEntry : BaseEntity<int>
|
||||
|
||||
public int OpenCountLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Server admin gate. True for live-capture-derived rows; false for synthesized stubs
|
||||
/// (operator opt-in per pack). Filtered in PackRepository.GetActivePacks; distinct from
|
||||
/// the wire-mirror IsHide.
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public PackGachaPointConfig? GachaPointConfig { get; set; }
|
||||
|
||||
public List<PackBannerEntry> Banners { get; set; } = new();
|
||||
|
||||
24
SVSim.Database/Models/PackDrawCardWeightEntry.cs
Normal file
24
SVSim.Database/Models/PackDrawCardWeightEntry.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-card-rate fact: which card prints in which (pack, slot, tier) at what rate.
|
||||
/// RatePct is nullable for rate-less "Guaranteed Leader Card" rows (sampler uses
|
||||
/// "uniform over (pool minus owned)" in that case).
|
||||
/// </summary>
|
||||
public class PackDrawCardWeightEntry : BaseEntity<long>
|
||||
{
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public override long Id { get; set; }
|
||||
|
||||
public int PackId { get; set; }
|
||||
public DrawSlot Slot { get; set; }
|
||||
public DrawTier Tier { get; set; }
|
||||
public long CardId { get; set; }
|
||||
public double? RatePct { get; set; }
|
||||
public bool IsLeader { get; set; }
|
||||
public bool IsAltArt { get; set; }
|
||||
}
|
||||
16
SVSim.Database/Models/PackDrawConfigEntry.cs
Normal file
16
SVSim.Database/Models/PackDrawConfigEntry.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per pack covered by drawrates data. PK is the pack id (matches PackConfigEntry.Id
|
||||
/// for live-capture rows; standalone for archive-only packs). Weak relationship — PackDraw rows
|
||||
/// exist for all archived packs even when no PackConfigEntry is enabled.
|
||||
/// </summary>
|
||||
public class PackDrawConfigEntry : BaseEntity<int>
|
||||
{
|
||||
public double AnimationRatePct { get; set; }
|
||||
public bool HasBonusSlot { get; set; }
|
||||
public string? SpecialKind { get; set; }
|
||||
public string? ShortCode { get; set; }
|
||||
}
|
||||
20
SVSim.Database/Models/PackDrawSlotRateEntry.cs
Normal file
20
SVSim.Database/Models/PackDrawSlotRateEntry.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per (pack, slot, tier) rate. Natural key (PackId, Slot, Tier) is enforced via unique index.
|
||||
/// Id is auto-generated — override BaseEntity's [DatabaseGenerated(None)] default.
|
||||
/// </summary>
|
||||
public class PackDrawSlotRateEntry : BaseEntity<long>
|
||||
{
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public override long Id { get; set; }
|
||||
|
||||
public int PackId { get; set; }
|
||||
public DrawSlot Slot { get; set; }
|
||||
public DrawTier Tier { get; set; }
|
||||
public double RatePct { get; set; }
|
||||
}
|
||||
28
SVSim.Database/Models/StoryDeckEntry.cs
Normal file
28
SVSim.Database/Models/StoryDeckEntry.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation metadata for a story-mode prebuilt/trial deck, as surfaced under
|
||||
/// main_story/get_deck_list's build_deck_list / trial_deck_list. PK (DeckNo) equals the deck's
|
||||
/// wire deck_no, which also equals BuildDeckProductEntry.Id — the 40-card list is read from that
|
||||
/// product (single source of truth), NOT stored here. Sourced from
|
||||
/// data_dumps/captures/traffic_prod_trial_decks.ndjson via seeds/story-decks.json.
|
||||
/// </summary>
|
||||
public class StoryDeckEntry : BaseEntity<int>
|
||||
{
|
||||
public int DeckNo { get => Id; set => Id = value; } // == BuildDeckProductEntry.Id
|
||||
|
||||
public StoryDeckKind Kind { get; set; }
|
||||
public int ClassId { get; set; }
|
||||
public string DeckName { get; set; } = string.Empty;
|
||||
public int SleeveId { get; set; }
|
||||
public int LeaderSkinId { get; set; }
|
||||
public int IsRecommend { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public int EntryNo { get; set; }
|
||||
|
||||
/// <summary>Trial decks carry a deck_format on the wire; build decks do not (null).</summary>
|
||||
public int? DeckFormat { get; set; }
|
||||
}
|
||||
@@ -65,6 +65,10 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
|
||||
|
||||
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
|
||||
|
||||
public List<ViewerGachaPointReceived> GachaPointReceived { get; set; } = new List<ViewerGachaPointReceived>();
|
||||
|
||||
public List<ViewerBuildDeckProductPurchase> BuildDeckPurchases { get; set; } = new List<ViewerBuildDeckProductPurchase>();
|
||||
|
||||
public List<ViewerMission> Missions { get; set; } = new List<ViewerMission>();
|
||||
|
||||
64
SVSim.Database/Models/ViewerArenaTwoPickRun.cs
Normal file
64
SVSim.Database/Models/ViewerArenaTwoPickRun.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One active Take Two run per viewer. Standalone (not a Viewer owned collection) to avoid
|
||||
/// the EF nav-include pitfalls in project_ef_nav_include_pitfall and to keep /load/index cheap.
|
||||
/// Row is deleted on /retire and /finish completion. Unique index on ViewerId enforces
|
||||
/// "one active run per viewer".
|
||||
/// <para>
|
||||
/// Lists are stored as jsonb strings (<c>{Field}Json</c>) per the project's inline-JSON column
|
||||
/// pattern (see DefaultDeckEntry.CardIdArray). Repos own (de)serialization.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Index(nameof(ViewerId), IsUnique = true)]
|
||||
public class ViewerArenaTwoPickRun
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long ViewerId { get; set; }
|
||||
|
||||
/// <summary>Wire <c>entry_info.id</c> / <c>two_pick_entry_id</c>. Set to <see cref="Id"/> on insert.</summary>
|
||||
public long EntryId { get; set; }
|
||||
|
||||
public int RewardScheduleId { get; set; }
|
||||
public int ChallengeId { get; set; }
|
||||
|
||||
/// <summary>MAX(reward.WinCount) at creation time. Stamped on the row so mid-run reward-table edits don't change the cap.</summary>
|
||||
public int MaxBattleCount { get; set; }
|
||||
|
||||
/// <summary>0 until /class_choose.</summary>
|
||||
public int ClassId { get; set; }
|
||||
|
||||
/// <summary>0 until first battle; set to class default on /class_choose.</summary>
|
||||
public long LeaderSkinId { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string CandidateClassIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>1..15.</summary>
|
||||
public int SelectTurn { get; set; }
|
||||
|
||||
public bool IsSelectCompleted { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string SelectedCardIdsJson { get; set; } = "[]";
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string PendingPickSetsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Monotonic counter for CandidatePair.Id; advances by 2 each draft turn.</summary>
|
||||
public long NextCandidateId { get; set; } = 1;
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string ResultListJson { get; set; } = "[]";
|
||||
|
||||
public int WinCount { get; set; }
|
||||
public int LossCount { get; set; }
|
||||
public bool IsRetire { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
17
SVSim.Database/Models/ViewerGachaPointBalance.cs
Normal file
17
SVSim.Database/Models/ViewerGachaPointBalance.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-viewer, per-pack gacha-point balance. Owned collection on <see cref="Viewer"/>.
|
||||
/// <c>PackId</c> = parent_gacha_id. <c>Points</c> accumulates one per pack opened (or
|
||||
/// <c>PackChildGachaEntry.OverrideIncreaseGachaPoint</c> when set on the child) and is
|
||||
/// decremented by <see cref="PackGachaPointConfig.ExchangeablePoint"/> per exchange.
|
||||
/// Unique index on (ViewerId, PackId) per project_owned_collection_unique_index.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class ViewerGachaPointBalance
|
||||
{
|
||||
public int PackId { get; set; }
|
||||
public int Points { get; set; }
|
||||
}
|
||||
17
SVSim.Database/Models/ViewerGachaPointReceived.cs
Normal file
17
SVSim.Database/Models/ViewerGachaPointReceived.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Marker row recording that a viewer has already redeemed <c>CardId</c> from <c>PackId</c>'s
|
||||
/// gacha-point exchange. Drives the per-entry <c>is_received</c> flag in
|
||||
/// <c>/pack/get_gacha_point_rewards</c>. Owned collection on <see cref="Viewer"/>.
|
||||
/// Unique index on (ViewerId, PackId, CardId) per project_owned_collection_unique_index.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class ViewerGachaPointReceived
|
||||
{
|
||||
public int PackId { get; set; }
|
||||
public long CardId { get; set; }
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
}
|
||||
@@ -64,4 +64,38 @@ public class BuildDeckRepository : IBuildDeckRepository
|
||||
await _db.SaveChangesAsync();
|
||||
return row.PurchaseCount;
|
||||
}
|
||||
|
||||
public async Task<List<StoryDeckView>> GetStoryDecksByClass(int classId)
|
||||
{
|
||||
var decks = await _db.StoryDecks.Where(d => d.ClassId == classId).ToListAsync();
|
||||
if (decks.Count == 0) return new();
|
||||
|
||||
var ids = decks.Select(d => d.DeckNo).ToList();
|
||||
var products = await _db.BuildDeckProducts
|
||||
.Where(p => ids.Contains(p.Id))
|
||||
.Include(p => p.Cards)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
// Expand each product's owned card rows by Number into a flat card_id list (spots included —
|
||||
// validated against the prod capture, 112/112 match).
|
||||
var cardsById = products.ToDictionary(
|
||||
p => p.Id,
|
||||
p => p.Cards.SelectMany(c => Enumerable.Repeat(c.CardId, c.Number)).ToList());
|
||||
|
||||
return decks.Select(d => new StoryDeckView
|
||||
{
|
||||
DeckNo = d.DeckNo,
|
||||
Kind = d.Kind,
|
||||
ClassId = d.ClassId,
|
||||
DeckName = d.DeckName,
|
||||
SleeveId = d.SleeveId,
|
||||
LeaderSkinId = d.LeaderSkinId,
|
||||
IsRecommend = d.IsRecommend,
|
||||
OrderNum = d.OrderNum,
|
||||
EntryNo = d.EntryNo,
|
||||
DeckFormat = d.DeckFormat,
|
||||
CardIdArray = cardsById.TryGetValue(d.DeckNo, out var cards) ? cards : new(),
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,11 @@ public interface IBuildDeckRepository
|
||||
/// Returns the new total.
|
||||
/// </summary>
|
||||
Task<int> IncrementPurchaseCount(long viewerId, int productId);
|
||||
|
||||
/// <summary>
|
||||
/// Story deck-select decks for a class: StoryDeckEntry presentation rows joined to the matching
|
||||
/// BuildDeckProductEntry card lists (deck_no == product_id), expanded to a flat card_id array.
|
||||
/// Returns build and trial decks together; the caller splits by Kind.
|
||||
/// </summary>
|
||||
Task<List<StoryDeckView>> GetStoryDecksByClass(int classId);
|
||||
}
|
||||
|
||||
22
SVSim.Database/Repositories/BuildDeck/StoryDeckView.cs
Normal file
22
SVSim.Database/Repositories/BuildDeck/StoryDeckView.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Repositories.BuildDeck;
|
||||
|
||||
/// <summary>
|
||||
/// A story-select deck ready for the wire: presentation metadata from StoryDeckEntry plus the
|
||||
/// 40-card list expanded from the matching BuildDeckProductEntry. Plain projection, not an entity.
|
||||
/// </summary>
|
||||
public sealed class StoryDeckView
|
||||
{
|
||||
public int DeckNo { get; init; }
|
||||
public StoryDeckKind Kind { get; init; }
|
||||
public int ClassId { get; init; }
|
||||
public string DeckName { get; init; } = string.Empty;
|
||||
public int SleeveId { get; init; }
|
||||
public int LeaderSkinId { get; init; }
|
||||
public int IsRecommend { get; init; }
|
||||
public int OrderNum { get; init; }
|
||||
public int EntryNo { get; init; }
|
||||
public int? DeckFormat { get; init; }
|
||||
public List<long> CardIdArray { get; init; } = new();
|
||||
}
|
||||
@@ -16,4 +16,16 @@ public class CollectionRepository : ICollectionRepository
|
||||
{
|
||||
return await _dbContext.Set<LeaderSkinEntry>().AsNoTracking().Include(skin => skin.Class).ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<int>> GetAllSleeveIds() =>
|
||||
_dbContext.Set<SleeveEntry>().AsNoTracking().Select(s => s.Id).ToListAsync();
|
||||
|
||||
public Task<List<int>> GetAllEmblemIds() =>
|
||||
_dbContext.Set<EmblemEntry>().AsNoTracking().Select(e => e.Id).ToListAsync();
|
||||
|
||||
public Task<List<int>> GetAllDegreeIds() =>
|
||||
_dbContext.Set<DegreeEntry>().AsNoTracking().Select(d => d.Id).ToListAsync();
|
||||
|
||||
public Task<List<int>> GetAllMyPageBackgroundIds() =>
|
||||
_dbContext.Set<MyPageBackgroundEntry>().AsNoTracking().Select(m => m.Id).ToListAsync();
|
||||
}
|
||||
@@ -5,4 +5,8 @@ namespace SVSim.Database.Repositories.Collectibles;
|
||||
public interface ICollectionRepository
|
||||
{
|
||||
Task<List<LeaderSkinEntry>> GetLeaderSkins();
|
||||
Task<List<int>> GetAllSleeveIds();
|
||||
Task<List<int>> GetAllEmblemIds();
|
||||
Task<List<int>> GetAllDegreeIds();
|
||||
Task<List<int>> GetAllMyPageBackgroundIds();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Globals;
|
||||
|
||||
public class ArenaTwoPickRewardRepository : IArenaTwoPickRewardRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public ArenaTwoPickRewardRepository(SVSimDbContext db) => _db = db;
|
||||
|
||||
public async Task<List<ArenaTwoPickReward>> GetRewardsByWinCountAsync(int winCount) =>
|
||||
await _db.ArenaTwoPickRewards
|
||||
.Where(r => r.WinCount == winCount)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetMaxWinCountAsync()
|
||||
{
|
||||
if (!await _db.ArenaTwoPickRewards.AnyAsync()) return 0;
|
||||
return await _db.ArenaTwoPickRewards.MaxAsync(r => r.WinCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Globals;
|
||||
|
||||
public interface IArenaTwoPickRewardRepository
|
||||
{
|
||||
Task<List<ArenaTwoPickReward>> GetRewardsByWinCountAsync(int winCount);
|
||||
Task<int> GetMaxWinCountAsync();
|
||||
}
|
||||
@@ -12,11 +12,11 @@ public class PackRepository : IPackRepository
|
||||
await _db.Packs
|
||||
.Include(p => p.ChildGachas)
|
||||
.Include(p => p.Banners)
|
||||
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
|
||||
.Where(p => p.IsEnabled && p.CommenceDate <= now && p.CompleteDate >= now)
|
||||
// parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack
|
||||
// UI runs with controls locked and auto-selects the FIRST entry in
|
||||
// pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the
|
||||
// tutorial to progress. Verified against data_dumps/traffic_prod_tutorial.ndjson —
|
||||
// tutorial to progress. Verified against data_dumps/captures/traffic_prod_tutorial.ndjson —
|
||||
// prod emits [99047, 92001, 80047, 16015..16011, 10032..10001].
|
||||
.OrderByDescending(p => p.Id)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
public interface IPackDrawTableRepository
|
||||
{
|
||||
/// <summary>Returns the draw table for <paramref name="packId"/>, or null if not seeded.</summary>
|
||||
Task<PackDrawTable?> GetAsync(int packId);
|
||||
}
|
||||
14
SVSim.Database/Repositories/PackDrawTable/PackDrawTable.cs
Normal file
14
SVSim.Database/Repositories/PackDrawTable/PackDrawTable.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
/// <summary>
|
||||
/// All draw data for a single pack: per-pack config + slot rates + per-card weights.
|
||||
/// Loaded as one unit by <see cref="IPackDrawTableRepository.GetAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class PackDrawTable
|
||||
{
|
||||
public required PackDrawConfigEntry Config { get; init; }
|
||||
public required IReadOnlyList<PackDrawSlotRateEntry> SlotRates { get; init; }
|
||||
public required IReadOnlyList<PackDrawCardWeightEntry> CardWeights { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Repositories.PackDrawTables;
|
||||
|
||||
public class PackDrawTableRepository : IPackDrawTableRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public PackDrawTableRepository(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public async Task<PackDrawTable?> GetAsync(int packId)
|
||||
{
|
||||
var config = await _db.PackDrawConfigs.FirstOrDefaultAsync(c => c.Id == packId);
|
||||
if (config is null) return null;
|
||||
|
||||
var slotRates = await _db.PackDrawSlotRates
|
||||
.Where(s => s.PackId == packId)
|
||||
.ToListAsync();
|
||||
|
||||
var cardWeights = await _db.PackDrawCardWeights
|
||||
.Where(w => w.PackId == packId)
|
||||
.ToListAsync();
|
||||
|
||||
return new PackDrawTable
|
||||
{
|
||||
Config = config,
|
||||
SlotRates = slotRates,
|
||||
CardWeights = cardWeights,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Viewer;
|
||||
|
||||
public class ArenaTwoPickRunRepository : IArenaTwoPickRunRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public ArenaTwoPickRunRepository(SVSimDbContext db) => _db = db;
|
||||
|
||||
public Task<ViewerArenaTwoPickRun?> GetByViewerIdAsync(long viewerId) =>
|
||||
_db.ViewerArenaTwoPickRuns.FirstOrDefaultAsync(r => r.ViewerId == viewerId);
|
||||
|
||||
public async Task UpsertAsync(ViewerArenaTwoPickRun run)
|
||||
{
|
||||
run.UpdatedAt = DateTime.UtcNow;
|
||||
if (run.Id == 0)
|
||||
{
|
||||
run.CreatedAt = DateTime.UtcNow;
|
||||
_db.ViewerArenaTwoPickRuns.Add(run);
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.ViewerArenaTwoPickRuns.Update(run);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long viewerId)
|
||||
{
|
||||
var row = await _db.ViewerArenaTwoPickRuns.FirstOrDefaultAsync(r => r.ViewerId == viewerId);
|
||||
if (row is null) return;
|
||||
_db.ViewerArenaTwoPickRuns.Remove(row);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Viewer;
|
||||
|
||||
public interface IArenaTwoPickRunRepository
|
||||
{
|
||||
Task<ViewerArenaTwoPickRun?> GetByViewerIdAsync(long viewerId);
|
||||
Task UpsertAsync(ViewerArenaTwoPickRun run);
|
||||
Task DeleteAsync(long viewerId);
|
||||
}
|
||||
@@ -68,8 +68,12 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
|
||||
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
|
||||
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
|
||||
public DbSet<PackDrawConfigEntry> PackDrawConfigs => Set<PackDrawConfigEntry>();
|
||||
public DbSet<PackDrawSlotRateEntry> PackDrawSlotRates => Set<PackDrawSlotRateEntry>();
|
||||
public DbSet<PackDrawCardWeightEntry> PackDrawCardWeights => Set<PackDrawCardWeightEntry>();
|
||||
public DbSet<BuildDeckSeriesEntry> BuildDeckSeries => Set<BuildDeckSeriesEntry>();
|
||||
public DbSet<BuildDeckProductEntry> BuildDeckProducts => Set<BuildDeckProductEntry>();
|
||||
public DbSet<StoryDeckEntry> StoryDecks => Set<StoryDeckEntry>();
|
||||
public DbSet<SleeveShopSeriesEntry> SleeveShopSeries => Set<SleeveShopSeriesEntry>();
|
||||
public DbSet<SleeveShopProductEntry> SleeveShopProducts => Set<SleeveShopProductEntry>();
|
||||
public DbSet<ItemPurchaseCatalogEntry> ItemPurchaseCatalog => Set<ItemPurchaseCatalogEntry>();
|
||||
@@ -97,6 +101,9 @@ public class SVSimDbContext : DbContext
|
||||
|
||||
public DbSet<ViewerClaimedTutorialGift> ViewerClaimedTutorialGifts => Set<ViewerClaimedTutorialGift>();
|
||||
|
||||
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
|
||||
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -145,6 +152,15 @@ public class SVSimDbContext : DbContext
|
||||
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
|
||||
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
|
||||
|
||||
modelBuilder.Entity<PackDrawSlotRateEntry>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier }).IsUnique();
|
||||
});
|
||||
modelBuilder.Entity<PackDrawCardWeightEntry>(e =>
|
||||
{
|
||||
e.HasIndex(x => new { x.PackId, x.Slot, x.Tier });
|
||||
});
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
|
||||
|
||||
// OwnedCardEntry and OwnedItemEntry use composite PK (ViewerId, Id) where Id is auto-
|
||||
@@ -167,6 +183,16 @@ public class SVSimDbContext : DbContext
|
||||
b.HasIndex("ViewerId", "ProductId").IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.GachaPointBalances, b =>
|
||||
{
|
||||
b.HasIndex("ViewerId", "PackId").IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Viewer>().OwnsMany(v => v.GachaPointReceived, b =>
|
||||
{
|
||||
b.HasIndex("ViewerId", "PackId", "CardId").IsUnique();
|
||||
});
|
||||
|
||||
// A given social account links to exactly one viewer — two viewers cannot share the same
|
||||
// Steam (or Facebook, etc.) account. This is the dedup backstop the auth handler's find-
|
||||
// or-link path (SteamSessionAuthenticationHandler) relies on: two concurrent first-Steam-
|
||||
|
||||
51
SVSim.Database/Services/CurrencySpendService.cs
Normal file
51
SVSim.Database/Services/CurrencySpendService.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
public class CurrencySpendService : ICurrencySpendService
|
||||
{
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
|
||||
|
||||
public Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
|
||||
{
|
||||
if (cost < 0) cost = 0;
|
||||
|
||||
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
|
||||
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
|
||||
{
|
||||
return Task.FromResult(new SpendResult(
|
||||
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
|
||||
}
|
||||
|
||||
ulong current = GetBalance(viewer, currency);
|
||||
if (current < (ulong)cost)
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
|
||||
|
||||
ulong post = current - (ulong)cost;
|
||||
SetBalance(viewer, currency, post);
|
||||
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
|
||||
}
|
||||
|
||||
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
|
||||
{
|
||||
SpendCurrency.Crystal => v.Currency.Crystals,
|
||||
SpendCurrency.Rupee => v.Currency.Rupees,
|
||||
SpendCurrency.RedEther => v.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(c)),
|
||||
};
|
||||
|
||||
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
|
||||
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
|
||||
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
|
||||
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
SVSim.Database/Services/ICurrencySpendService.cs
Normal file
14
SVSim.Database/Services/ICurrencySpendService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
|
||||
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
|
||||
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
|
||||
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
|
||||
/// </summary>
|
||||
public interface ICurrencySpendService
|
||||
{
|
||||
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
|
||||
}
|
||||
54
SVSim.Database/Services/IViewerEntitlements.cs
Normal file
54
SVSim.Database/Services/IViewerEntitlements.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the
|
||||
/// Freeplay flag; all freeplay read-side behavior lives here. See
|
||||
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
|
||||
/// viewer to have been loaded with <c>.Include(v => v.Cards).ThenInclude(c => c.Card)</c>
|
||||
/// and the cosmetic collections
|
||||
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
|
||||
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
|
||||
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
|
||||
/// </remarks>
|
||||
public interface IViewerEntitlements
|
||||
{
|
||||
/// <summary>True when the global Freeplay config section is enabled.</summary>
|
||||
bool IsFreeplay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The balance the viewer is treated as having: the configured freeplay amount for
|
||||
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
|
||||
/// <c>viewer.Currency</c> field.
|
||||
/// </summary>
|
||||
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
|
||||
|
||||
bool OwnsCard(Viewer viewer, long cardId);
|
||||
|
||||
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
|
||||
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
|
||||
|
||||
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
|
||||
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
|
||||
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
|
||||
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
|
||||
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
|
||||
/// </summary>
|
||||
public sealed record EffectiveCosmetics(
|
||||
IReadOnlyList<int> SleeveIds,
|
||||
IReadOnlyList<int> EmblemIds,
|
||||
IReadOnlyList<int> DegreeIds,
|
||||
IReadOnlyList<int> MyPageBackgroundIds,
|
||||
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
|
||||
IReadOnlySet<int> OwnedLeaderSkinIds);
|
||||
16
SVSim.Database/Services/SpendCurrency.cs
Normal file
16
SVSim.Database/Services/SpendCurrency.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>The scalar wallet currencies the central debit primitive understands.</summary>
|
||||
public enum SpendCurrency { Crystal, Rupee, RedEther, SpotPoint }
|
||||
|
||||
public enum SpendOutcome { Success, Insufficient }
|
||||
|
||||
/// <summary>
|
||||
/// Result of a <see cref="ICurrencySpendService.TrySpendAsync"/> call. <see cref="PostStateTotal"/>
|
||||
/// is the balance the client should show after the spend — the real post-deduction balance, or the
|
||||
/// freeplay effective balance when the spend was a freeplay no-op.
|
||||
/// </summary>
|
||||
public sealed record SpendResult(SpendOutcome Outcome, long PostStateTotal)
|
||||
{
|
||||
public bool Success => Outcome == SpendOutcome.Success;
|
||||
}
|
||||
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal file
107
SVSim.Database/Services/ViewerEntitlements.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
public class ViewerEntitlements : IViewerEntitlements
|
||||
{
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly ICardRepository _cards;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection)
|
||||
{
|
||||
_config = config;
|
||||
_cards = cards;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
private FreeplayConfig Cfg => _config.Get<FreeplayConfig>();
|
||||
|
||||
public bool IsFreeplay => Cfg.Enabled;
|
||||
|
||||
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
|
||||
return checked((long)cfg.CurrencyAmount);
|
||||
|
||||
return currency switch
|
||||
{
|
||||
SpendCurrency.Crystal => (long)viewer.Currency.Crystals,
|
||||
SpendCurrency.Rupee => (long)viewer.Currency.Rupees,
|
||||
SpendCurrency.RedEther => (long)viewer.Currency.RedEther,
|
||||
SpendCurrency.SpotPoint => (long)viewer.Currency.SpotPoints,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(currency)),
|
||||
};
|
||||
}
|
||||
|
||||
public bool OwnsCard(Viewer viewer, long cardId)
|
||||
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
|
||||
|
||||
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id)
|
||||
{
|
||||
if (Cfg.Enabled) return true;
|
||||
return type switch
|
||||
{
|
||||
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
|
||||
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
|
||||
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
|
||||
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
|
||||
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var defaults = await _cards.GetDefaultCards();
|
||||
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
|
||||
var cfg = Cfg;
|
||||
|
||||
if (cfg.Enabled)
|
||||
{
|
||||
var all = await _cards.GetAll(onlyCollectible: true);
|
||||
return all
|
||||
.Select(c => new OwnedCardEntry
|
||||
{
|
||||
Card = c,
|
||||
Count = cfg.CardCopies,
|
||||
IsProtected = defaultIds.Contains(c.Id),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var owned = viewer.Cards.Where(c => c.Count > 0 && !defaultIds.Contains(c.Card.Id));
|
||||
return owned
|
||||
.Concat(defaults.Select(bc => new OwnedCardEntry { Card = bc, Count = 3, IsProtected = true }))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
|
||||
{
|
||||
var allSkins = await _collection.GetLeaderSkins();
|
||||
|
||||
if (Cfg.Enabled)
|
||||
{
|
||||
return new EffectiveCosmetics(
|
||||
await _collection.GetAllSleeveIds(),
|
||||
await _collection.GetAllEmblemIds(),
|
||||
await _collection.GetAllDegreeIds(),
|
||||
await _collection.GetAllMyPageBackgroundIds(),
|
||||
allSkins,
|
||||
allSkins.Select(s => s.Id).ToHashSet());
|
||||
}
|
||||
|
||||
return new EffectiveCosmetics(
|
||||
viewer.Sleeves.Select(s => s.Id).ToList(),
|
||||
viewer.Emblems.Select(e => e.Id).ToList(),
|
||||
viewer.Degrees.Select(d => d.Id).ToList(),
|
||||
viewer.MyPageBackgrounds.Select(m => m.Id).ToList(),
|
||||
allSkins,
|
||||
viewer.LeaderSkins.Select(s => s.Id).ToHashSet());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
|
||||
@@ -20,11 +21,14 @@ public class AdminController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
|
||||
ILogger<AdminController> logger)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,6 +85,9 @@ public class AdminController : SVSimController
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Cards)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
|
||||
@@ -124,25 +131,145 @@ public class AdminController : SVSimController
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
|
||||
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
|
||||
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
|
||||
// _deckGroup is null when GetCustomDeckGroup() finds no matching CustomDeck group in
|
||||
// DeckGroupDataBase — which is exactly what happens for a fresh viewer). Prod players
|
||||
// acquire decks via tutorial; we shortcut by seeding the 8 defaults at import time.
|
||||
// See docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md for the full background.
|
||||
if (wasCreated)
|
||||
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
|
||||
// that aren't in our card master. Surfaced in the response and logged after save.
|
||||
var skippedCardIds = new HashSet<long>();
|
||||
|
||||
if (request.OwnedCards is not null)
|
||||
{
|
||||
await CloneDefaultDecksToViewerAsync(viewer);
|
||||
var wanted = request.OwnedCards
|
||||
.GroupBy(c => c.CardId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
var ids = wanted.Select(c => c.CardId).ToList();
|
||||
var cardMaster = await _dbContext.Cards
|
||||
.Where(c => ids.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
viewer.Cards.Clear();
|
||||
foreach (var c in wanted)
|
||||
{
|
||||
if (!cardMaster.TryGetValue(c.CardId, out var card))
|
||||
{
|
||||
skippedCardIds.Add(c.CardId);
|
||||
continue;
|
||||
}
|
||||
viewer.Cards.Add(new OwnedCardEntry
|
||||
{
|
||||
Card = card,
|
||||
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
|
||||
IsProtected = c.IsProtected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Items is not null)
|
||||
{
|
||||
var wanted = request.Items
|
||||
.GroupBy(i => i.ItemId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
var ids = wanted.Select(i => i.ItemId).ToList();
|
||||
var itemMaster = await _dbContext.Items
|
||||
.Where(i => ids.Contains(i.Id))
|
||||
.ToDictionaryAsync(i => i.Id);
|
||||
|
||||
viewer.Items.Clear();
|
||||
foreach (var i in wanted)
|
||||
{
|
||||
if (!itemMaster.TryGetValue(i.ItemId, out var item)) continue; // unknown master id
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = i.Count, Viewer = viewer });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Decks is not null)
|
||||
{
|
||||
var allDeckCardIds = request.Decks
|
||||
.Where(d => d.CardIdArray is not null)
|
||||
.SelectMany(d => d.CardIdArray!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var deckCardMaster = await _dbContext.Cards
|
||||
.Where(c => allDeckCardIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
|
||||
var sleeves = await _dbContext.Sleeves.ToDictionaryAsync(s => (long)s.Id);
|
||||
var leaderSkins = await _dbContext.LeaderSkins.ToDictionaryAsync(s => s.Id);
|
||||
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
|
||||
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
|
||||
.Select(s => (int?)s.Id)
|
||||
.OrderByDescending(id => id)
|
||||
.FirstOrDefaultAsync())?.ToString();
|
||||
|
||||
_dbContext.RemoveRange(viewer.Decks);
|
||||
viewer.Decks.Clear();
|
||||
|
||||
foreach (var d in request.Decks)
|
||||
{
|
||||
// A /load/index dump carries every deck slot, most of them empty placeholders
|
||||
// (no cards). Skip them: the client manages empty slots itself (it's why the old
|
||||
// default-deck cloning was removed), and importing empty MyRotation slots would
|
||||
// otherwise persist decks with a bogus rotation id.
|
||||
if ((d.CardIdArray?.Count ?? 0) == 0) continue;
|
||||
|
||||
Format format;
|
||||
try { format = FormatExtensions.FromApi(d.DeckFormat); }
|
||||
catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format
|
||||
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
|
||||
|
||||
SleeveEntry? sleeve = null;
|
||||
if (d.SleeveId.HasValue) sleeves.TryGetValue(d.SleeveId.Value, out sleeve);
|
||||
sleeve ??= defaultSleeve;
|
||||
|
||||
LeaderSkinEntry? leaderSkin = null;
|
||||
if (d.LeaderSkinId.HasValue) leaderSkins.TryGetValue(d.LeaderSkinId.Value, out leaderSkin);
|
||||
leaderSkin ??= classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
|
||||
|
||||
if (sleeve is null || leaderSkin is null) continue;
|
||||
|
||||
var cards = (d.CardIdArray ?? new List<long>())
|
||||
.GroupBy(id => id)
|
||||
.Where(g =>
|
||||
{
|
||||
if (deckCardMaster.ContainsKey(g.Key)) return true;
|
||||
skippedCardIds.Add(g.Key);
|
||||
return false;
|
||||
})
|
||||
.Select(g => new DeckCard { Card = deckCardMaster[g.Key], Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
viewer.Decks.Add(new ShadowverseDeckEntry
|
||||
{
|
||||
Name = d.DeckName ?? $"Deck {d.DeckNo}",
|
||||
Number = d.DeckNo,
|
||||
Format = format,
|
||||
Class = classEntry,
|
||||
Sleeve = sleeve,
|
||||
LeaderSkin = leaderSkin,
|
||||
RandomLeaderSkin = (d.IsRandomLeaderSkin ?? 0) != 0,
|
||||
Cards = cards,
|
||||
MyRotationId = format == Format.MyRotation ? (d.MyRotationId ?? latestMyRotationId) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
if (skippedCardIds.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
|
||||
"card_id(s) not present in the card master. Sample: [{Sample}]",
|
||||
request.SteamId, viewer.Id, skippedCardIds.Count,
|
||||
string.Join(", ", skippedCardIds.Take(20)));
|
||||
}
|
||||
|
||||
return new ImportViewerResponse
|
||||
{
|
||||
ViewerId = viewer.Id,
|
||||
ShortUdid = viewer.ShortUdid,
|
||||
WasCreated = wasCreated
|
||||
WasCreated = wasCreated,
|
||||
SkippedCardCount = skippedCardIds.Count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,81 +289,8 @@ public class AdminController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default sleeve id used for cloned starter decks. Matches prod's wire shape — every
|
||||
/// default_deck_list entry on /deck/info has <c>sleeve_id: 3000011</c>.
|
||||
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
|
||||
/// 3000011 is prod's default deck sleeve.
|
||||
/// </summary>
|
||||
private const long DefaultSleeveId = 3000011L;
|
||||
|
||||
/// <summary>
|
||||
/// Formats we clone the starter decks into. Each format the player can open the deck-edit
|
||||
/// menu for needs at least one CustomDeck group in <c>Data.DeckGroupDataBase</c>, otherwise
|
||||
/// the client NREs on <c>_deckGroup.DeckFormat</c> in DeckListUI.IsVisibleCreateNewButton.
|
||||
/// Rotation / Unlimited / MyRotation are the always-active base formats; PreRotation /
|
||||
/// Crossover / Avatar are seasonal and gated by UI state — leave them empty for now (see
|
||||
/// docs/audits/deck-edit-empty-decklist-nre-2026-05-23.md follow-ups).
|
||||
/// </summary>
|
||||
private static readonly Format[] SeededDeckFormats = { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the 8 default decks into the viewer's deck collection, once per seeded format.
|
||||
/// The tracked <paramref name="viewer"/> instance gets new ShadowverseDeckEntry rows added to
|
||||
/// its Decks navigation; EF picks them up on the caller's SaveChangesAsync.
|
||||
/// </summary>
|
||||
private async Task CloneDefaultDecksToViewerAsync(Viewer viewer)
|
||||
{
|
||||
var defaultDecks = await _dbContext.DefaultDecks.AsNoTracking().OrderBy(d => d.Id).ToListAsync();
|
||||
if (defaultDecks.Count == 0) return;
|
||||
|
||||
// Resolve nav-property entities once. Classes need LeaderSkins included for the
|
||||
// DefaultLeaderSkin nav lookup. Cards are fetched in one bulk query keyed by id.
|
||||
var classes = await _dbContext.Classes.Include(c => c.LeaderSkins).ToDictionaryAsync(c => c.Id);
|
||||
var defaultSleeve = await _dbContext.Sleeves.FindAsync((int)DefaultSleeveId);
|
||||
|
||||
var allCardIds = defaultDecks
|
||||
.SelectMany(d => JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
|
||||
|
||||
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
|
||||
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
|
||||
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
|
||||
// rotation id available — it includes the most recent pack and therefore covers every
|
||||
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
|
||||
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
|
||||
.Select(s => (int?)s.Id)
|
||||
.OrderByDescending(id => id)
|
||||
.FirstOrDefaultAsync())?.ToString();
|
||||
|
||||
foreach (var format in SeededDeckFormats)
|
||||
{
|
||||
int slot = 1;
|
||||
foreach (var d in defaultDecks)
|
||||
{
|
||||
if (!classes.TryGetValue(d.ClassId, out var classEntry)) continue;
|
||||
var leaderSkin = classEntry.DefaultLeaderSkin ?? classEntry.LeaderSkins.FirstOrDefault();
|
||||
if (leaderSkin is null || defaultSleeve is null) continue;
|
||||
|
||||
var cardIdArray = JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions.Instance) ?? new List<long>();
|
||||
var deckCards = cardIdArray
|
||||
.GroupBy(id => id)
|
||||
.Where(g => cards.ContainsKey(g.Key))
|
||||
.Select(g => new DeckCard { Card = cards[g.Key], Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
viewer.Decks.Add(new ShadowverseDeckEntry
|
||||
{
|
||||
Name = d.DeckName,
|
||||
Number = slot++,
|
||||
Format = format,
|
||||
Class = classEntry,
|
||||
Sleeve = defaultSleeve,
|
||||
LeaderSkin = leaderSkin,
|
||||
RandomLeaderSkin = false,
|
||||
Cards = deckCards,
|
||||
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Stub controller for the Colosseum arena family. Currently only emits a "no Colosseum
|
||||
/// period" /get_fee_info response so the home/arena screen doesn't 404. The full Colosseum
|
||||
/// flow (top, entry, register_deck, event_info, retire, finish, class_choose, card_choose,
|
||||
/// matchmaking) is deferred — see Wizard/ColosseumEntryInfoTask.cs for the parser surface.
|
||||
/// </summary>
|
||||
[Route("arena_colosseum")]
|
||||
public class ArenaColosseumController : SVSimController
|
||||
{
|
||||
[HttpPost("get_fee_info")]
|
||||
public IActionResult GetFeeInfo([FromBody] GetFeeInfoRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
return Ok(new GetFeeInfoResponseDto());
|
||||
}
|
||||
}
|
||||
75
SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs
Normal file
75
SVSim.EmulatedEntrypoint/Controllers/ArenaController.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Arena;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Arena;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Generic /arena/* family — primarily challenge-history info read by the TK2 entry screen's
|
||||
/// detail button. TODO: lifetime TK2 stats tracking; today we emit a stub.
|
||||
/// </summary>
|
||||
[Route("arena")]
|
||||
public class ArenaController : SVSimController
|
||||
{
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
|
||||
public ArenaController(IGlobalsRepository globalsRepository)
|
||||
{
|
||||
_globalsRepository = globalsRepository;
|
||||
}
|
||||
|
||||
[HttpPost("get_challenge_info")]
|
||||
public async Task<IActionResult> GetChallengeInfo([FromBody] GetChallengeInfoRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
|
||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
||||
// Best-effort: pull begin/end_time + name from the season seed when present; otherwise
|
||||
// emit deterministic stub values. All 6 ChallangeHistoryInfoTask.Parse fields must be
|
||||
// present — the parser accesses them unconditionally.
|
||||
var beginTime = "2026-05-01 02:00:00";
|
||||
var endTime = "2026-06-01 01:59:59";
|
||||
var name = "Take Two";
|
||||
if (season is not null && !string.IsNullOrEmpty(season.FormatInfo) && season.FormatInfo != "{}")
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(season.FormatInfo);
|
||||
if (doc.RootElement.TryGetProperty("start_time", out var st)) beginTime = st.GetString() ?? beginTime;
|
||||
if (doc.RootElement.TryGetProperty("end_time", out var et)) endTime = et.GetString() ?? endTime;
|
||||
if (doc.RootElement.TryGetProperty("card_pool_name", out var cp)) name = cp.GetString() ?? name;
|
||||
}
|
||||
catch { /* fall back to defaults */ }
|
||||
}
|
||||
|
||||
// Default Challenge Master reward steps from prod capture: 3 milestones at 5/10/15 wins.
|
||||
var rewardSteps = new Dictionary<string, string>
|
||||
{
|
||||
["5"] = "5",
|
||||
["10"] = "10",
|
||||
["15"] = "15",
|
||||
};
|
||||
|
||||
return Ok(new GetChallengeInfoResponseDto
|
||||
{
|
||||
ChallengeName = name,
|
||||
BeginTime = beginTime,
|
||||
EndTime = endTime,
|
||||
TwoPickAllWinCount = 0,
|
||||
RewardStepInfo = new RewardStepInfoDto
|
||||
{
|
||||
MaxRewardStep = 15,
|
||||
RewardStepList = rewardSteps,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("get_challenge_ranking_history")]
|
||||
public IActionResult GetChallengeRankingHistory([FromBody] GetChallengeInfoRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
// Prod returns {two_pick: [], sealed: []}. Stub matches.
|
||||
return Ok(new GetChallengeRankingHistoryResponseDto());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
[Route("arena_two_pick_battle")]
|
||||
public class ArenaTwoPickBattleController : SVSimController
|
||||
{
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
public ArenaTwoPickBattleController(IArenaTwoPickService svc) => _svc = svc;
|
||||
|
||||
[HttpPost("do_matching")]
|
||||
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out _)) return Unauthorized();
|
||||
return Ok(new DoMatchingResponseDto());
|
||||
}
|
||||
|
||||
[HttpPost("finish")]
|
||||
public async Task<IActionResult> Finish([FromBody] BattleFinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
try
|
||||
{
|
||||
var result = await _svc.RecordBattleResultAsync(vid, req.BattleResult == 1);
|
||||
return Ok(new BattleFinishResponseDto
|
||||
{
|
||||
BattleResult = result.BattleResult,
|
||||
GetClassExperience = result.GetClassExperience,
|
||||
ClassExperience = result.ClassExperience,
|
||||
ClassLevel = result.ClassLevel,
|
||||
SpotPointInfo = new SpotPointInfoDto
|
||||
{
|
||||
BeforeSpotPoint = result.BeforeSpotPoint,
|
||||
AddSpotPoint = result.AddSpotPoint,
|
||||
AfterSpotPoint = result.AfterSpotPoint,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (ArenaTwoPickException ex)
|
||||
{
|
||||
return BadRequest(new { error_code = ex.ErrorCode });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
[Route("arena_two_pick")]
|
||||
public class ArenaTwoPickController : SVSimController
|
||||
{
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
public ArenaTwoPickController(IArenaTwoPickService svc) => _svc = svc;
|
||||
|
||||
[HttpPost("top")]
|
||||
public async Task<IActionResult> Top([FromBody] TopRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return Ok(await _svc.GetTopAsync(vid));
|
||||
}
|
||||
|
||||
[HttpPost("entry")]
|
||||
public async Task<IActionResult> Entry([FromBody] EntryRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.EntryAsync(vid, req.ConsumeItemType));
|
||||
}
|
||||
|
||||
[HttpPost("class_choose")]
|
||||
public async Task<IActionResult> ClassChoose([FromBody] ClassChooseRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.ChooseClassAsync(vid, req.ClassId));
|
||||
}
|
||||
|
||||
[HttpPost("card_choose")]
|
||||
public async Task<IActionResult> CardChoose([FromBody] CardChooseRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.ChooseCardAsync(vid, req.SelectedId));
|
||||
}
|
||||
|
||||
[HttpPost("retire")]
|
||||
public async Task<IActionResult> Retire([FromBody] RetireRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.RetireAsync(vid));
|
||||
}
|
||||
|
||||
[HttpPost("finish")]
|
||||
public async Task<IActionResult> Finish([FromBody] FinishRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.FinishAsync(vid));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GuardAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
try { return Ok(await action()); }
|
||||
catch (ArenaTwoPickException ex) { return BadRequest(new { error_code = ex.ErrorCode }); }
|
||||
}
|
||||
}
|
||||
@@ -22,15 +22,18 @@ public class BuildDeckController : SVSimController
|
||||
private readonly IBuildDeckRepository _repo;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
|
||||
public BuildDeckController(
|
||||
IBuildDeckRepository repo,
|
||||
SVSimDbContext db,
|
||||
RewardGrantService rewards)
|
||||
RewardGrantService rewards,
|
||||
ICurrencySpendService spend)
|
||||
{
|
||||
_repo = repo;
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_spend = spend;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,19 +203,15 @@ public class BuildDeckController : SVSimController
|
||||
// Debit + post-state currency entry
|
||||
if (request.SalesType == 1)
|
||||
{
|
||||
ulong cost = (ulong)priceCrystal!.Value;
|
||||
if (viewer.Currency.Crystals < cost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= cost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
|
||||
}
|
||||
else if (request.SalesType == 2)
|
||||
{
|
||||
ulong cost = (ulong)priceRupy!.Value;
|
||||
if (viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= cost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
|
||||
}
|
||||
// sales_type == 0 (free): no debit, no currency entry
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Check;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
@@ -67,4 +68,19 @@ public class CheckController : SVSimController
|
||||
KorAuthorityId = 0
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Card-master rotation-period integrity probe. Wire path is
|
||||
/// <c>check/check_time_slip_card_master_hash</c> but the client task is
|
||||
/// <c>CheckTimeSlipRotationPeriodTask</c> — a pure <c>BaseTask</c> with no
|
||||
/// <c>Parse()</c> override (Wizard/CheckTimeSlipRotationPeriodTask.cs). Fired from
|
||||
/// <c>DeckDecisionUI.cs:140</c> (Arena "View Deck" path) and the TK2 prep screen.
|
||||
/// Prod responds with <c>data: []</c> in every observed capture across
|
||||
/// traffic_prod_taketwo_selections.ndjson + traffic_prod_tradeables_capture.ndjson.
|
||||
/// </summary>
|
||||
[HttpPost("check_time_slip_card_master_hash")]
|
||||
public IActionResult CheckTimeSlipCardMasterHash([FromBody] CheckTimeSlipCardMasterHashRequest req)
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ public class DeckBuilderController : ControllerBase
|
||||
Clan = req.Clan.ToString(),
|
||||
SubClan = req.SubClan ?? 0,
|
||||
// Standard decks emit int 0; my-rotation decks emit the rotation id as a string.
|
||||
// Mixed wire typing matches prod (data_dumps/traffic_prod_deckcode.ndjson).
|
||||
// Mixed wire typing matches prod (data_dumps/captures/traffic_prod_deckcode.ndjson).
|
||||
RotationId = (object?)req.RotationId ?? 0,
|
||||
// Strip the foil flag (ones digit) — matches prod's normalize-on-encode behaviour
|
||||
// observed in the traffic dump (e.g. 703441011 → 703441010).
|
||||
|
||||
@@ -14,48 +14,21 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
public class DeckController : SVSimController
|
||||
{
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
private readonly DeckOptions _deckOptions;
|
||||
private readonly IDeckListBuilder _deckListBuilder;
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions JsonbReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower,
|
||||
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public DeckController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository, SVSimDbContext dbContext, IOptions<DeckOptions> deckOptions)
|
||||
public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext, IDeckListBuilder deckListBuilder)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_dbContext = dbContext;
|
||||
_deckOptions = deckOptions.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pads a viewer's real deck list with empty-slot placeholders up to <see cref="DeckOptions.MaxDeckSlots"/>.
|
||||
/// Required because the client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> only renders
|
||||
/// a "New Deck" tile when the response contains an entry whose <c>card_id_array</c> is empty —
|
||||
/// without padding, the player cannot create additional decks once any exist.
|
||||
/// </summary>
|
||||
private List<UserDeck> PadEmptySlots(List<UserDeck> realDecks)
|
||||
{
|
||||
var taken = realDecks.Select(d => d.DeckNumber).ToHashSet();
|
||||
var result = new List<UserDeck>(realDecks);
|
||||
for (int slot = 1; slot <= _deckOptions.MaxDeckSlots; slot++)
|
||||
{
|
||||
if (!taken.Contains(slot))
|
||||
{
|
||||
result.Add(UserDeck.CreateEmptySlot(slot));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
_deckListBuilder = deckListBuilder;
|
||||
}
|
||||
|
||||
// Request deck_format fields arrive as wire ints (MessagePack-CSharp doesn't honor STJ
|
||||
@@ -68,93 +41,15 @@ public class DeckController : SVSimController
|
||||
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
|
||||
// Deck builder screen: pad empty "New Deck" slots so the player can create more decks.
|
||||
return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true);
|
||||
}
|
||||
|
||||
[HttpPost("my_list")]
|
||||
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
return await BuildDeckListResponseAsync(viewerId, AsFormat(request.DeckFormat));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared hydration for <c>/deck/info</c> and <c>/deck/my_list</c> — both endpoints return the
|
||||
/// same <see cref="DeckListResponse"/> DTO and the client's DeckInfoTask.Parse / DeckMyListTask.Parse
|
||||
/// are identical (both call <c>DeckGroupListData(jsonData, format)</c>).
|
||||
///
|
||||
/// Wire shape swaps based on the request format. When the client asks for All-format
|
||||
/// (<c>deck_format=0</c>), prod emits per-format keys (<c>user_deck_rotation</c>, etc.);
|
||||
/// for a specific format request, prod emits a single <c>user_deck_list</c>. The client's
|
||||
/// <c>DeckListUtility.ParseDeckInfoResponceData</c> branches on these two shapes, so the
|
||||
/// controller mirrors it exactly.
|
||||
/// </summary>
|
||||
private async Task<DeckListResponse> BuildDeckListResponseAsync(long viewerId, Format requestFormat)
|
||||
{
|
||||
var defaultDecks = await _globalsRepository.GetDefaultDecks();
|
||||
|
||||
// user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite
|
||||
// the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's
|
||||
// ViewerClassData rows, matching how /load/index's user_class_list reads them. The global
|
||||
// DefaultLeaderSkinSettings table is now used only as initial seed values for fresh
|
||||
// viewers (ViewerRepository.RegisterViewer); the per-class current skin is on
|
||||
// viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update.
|
||||
var viewerClasses = await _dbContext.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Classes)
|
||||
.Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id })
|
||||
.ToListAsync();
|
||||
|
||||
var response = new DeckListResponse
|
||||
{
|
||||
DefaultDeckList = defaultDecks.ToDictionary(
|
||||
d => d.Id.ToString(),
|
||||
d => new DefaultDeck
|
||||
{
|
||||
DeckNo = d.DeckNo,
|
||||
ClassId = d.ClassId,
|
||||
SleeveId = d.SleeveId,
|
||||
LeaderSkinId = d.LeaderSkinId,
|
||||
DeckName = d.DeckName,
|
||||
CardIdArray = System.Text.Json.JsonSerializer.Deserialize<List<long>>(d.CardIdArray, JsonbReadOptions) ?? new(),
|
||||
// TODO(deck-stub): wire from real per-deck state once user maintenance / availability tracking lands.
|
||||
// Prod emits is_complete_deck=1, is_available_deck=1, maintenance_card_ids=[] for the 8 starter decks.
|
||||
IsCompleteDeck = 1,
|
||||
IsAvailableDeck = 1,
|
||||
MaintenanceCardIds = new(),
|
||||
}),
|
||||
UserLeaderSkinSettingList = viewerClasses.ToDictionary(
|
||||
vc => vc.Id.ToString(),
|
||||
vc => new UserLeaderSkinSetting
|
||||
{
|
||||
ClassId = vc.Id,
|
||||
IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted
|
||||
LeaderSkinId = vc.LeaderSkinId,
|
||||
}),
|
||||
MaintenanceCardList = new(), // sourced from same place as /load/index when wired
|
||||
};
|
||||
|
||||
if (requestFormat == Format.All)
|
||||
{
|
||||
// Prod's All-format response emits these three per-format lists (each [] for fresh viewers).
|
||||
// The PreRotation / Crossover / Avatar siblings exist in client code but prod omits them
|
||||
// for our profile; we mirror that omission and leave the nullable DTO fields unset.
|
||||
var formats = new[] { Format.Rotation, Format.Unlimited, Format.MyRotation };
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, formats);
|
||||
response.UserDeckRotation = PadEmptySlots(byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList());
|
||||
response.UserDeckUnlimited = PadEmptySlots(byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList());
|
||||
response.UserDeckMyRotation = PadEmptySlots(byFormat[Format.MyRotation].Select(d => new UserDeck(d)).ToList());
|
||||
// trial_deck_list is prod-emitted on /deck/info (All format) but omitted on /deck/my_list
|
||||
// (specific format). Empty array in the 2026-05-23 prod capture.
|
||||
response.TrialDeckList = new();
|
||||
}
|
||||
else
|
||||
{
|
||||
var decks = await _deckRepository.GetDecks(viewerId, requestFormat);
|
||||
response.UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList());
|
||||
}
|
||||
|
||||
return response;
|
||||
return await _deckListBuilder.BuildAsync(viewerId, AsFormat(request.DeckFormat), padEmptySlots: true);
|
||||
}
|
||||
|
||||
[HttpPost("get_empty_deck_number")]
|
||||
@@ -201,7 +96,7 @@ public class DeckController : SVSimController
|
||||
var decks = await _deckRepository.GetDecks(viewerId, format);
|
||||
return new DeckUpdateResponse
|
||||
{
|
||||
UserDeckList = PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList())
|
||||
UserDeckList = _deckListBuilder.PadEmptySlots(decks.Select(d => new UserDeck(d)).ToList())
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ public class ItemPurchaseController : SVSimController
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
|
||||
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -117,7 +119,7 @@ public class ItemPurchaseController : SVSimController
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
|
||||
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
@@ -160,29 +162,29 @@ public class ItemPurchaseController : SVSimController
|
||||
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
|
||||
/// uses to refresh its cached count. Returns an error string on insufficient balance.
|
||||
/// </summary>
|
||||
private static (RewardListEntry? PostState, string? Error) TryDebit(
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.RedEther:
|
||||
if (viewer.Currency.RedEther < (ulong)num)
|
||||
return (null, "insufficient_red_ether");
|
||||
viewer.Currency.RedEther -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
|
||||
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
|
||||
if (!r.Success) return (null, "insufficient_red_ether");
|
||||
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Crystal:
|
||||
if (viewer.Currency.Crystals < (ulong)num)
|
||||
return (null, "insufficient_crystals");
|
||||
viewer.Currency.Crystals -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
|
||||
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
|
||||
if (!r.Success) return (null, "insufficient_crystals");
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Rupy:
|
||||
if (viewer.Currency.Rupees < (ulong)num)
|
||||
return (null, "insufficient_rupees");
|
||||
viewer.Currency.Rupees -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
|
||||
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
|
||||
if (!r.Success) return (null, "insufficient_rupees");
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
case UserGoodsType.Item:
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null || owned.Count < num)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
@@ -30,12 +31,18 @@ public class LeaderSkinController : SVSimController
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
[HttpPost("set")]
|
||||
@@ -62,7 +69,7 @@ public class LeaderSkinController : SVSimController
|
||||
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
||||
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
||||
if (viewer.LeaderSkins.All(s => s.Id != skin.Id))
|
||||
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
|
||||
return BadRequest(new { error = "skin_not_owned" });
|
||||
|
||||
classData.LeaderSkin = skin;
|
||||
@@ -81,6 +88,12 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
{
|
||||
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
|
||||
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
|
||||
}
|
||||
|
||||
var ids = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
@@ -95,10 +108,12 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var ownedSkinIds = (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
var ownedSkinIds = _entitlements.IsFreeplay
|
||||
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
|
||||
: (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
|
||||
.Where(c => c.ViewerId == viewerId)
|
||||
@@ -171,11 +186,11 @@ public class LeaderSkinController : SVSimController
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
|
||||
// Already-purchased = viewer owns the leader_skin this product grants.
|
||||
if (viewer.LeaderSkins.Any(s => s.Id == product.LeaderSkinId))
|
||||
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = DebitProductPrice(viewer, product, request.SalesType);
|
||||
var debit = await DebitProductPrice(viewer, product, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
@@ -205,8 +220,11 @@ public class LeaderSkinController : SVSimController
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = DebitSetPrice(viewer, series, request.SalesType);
|
||||
var debit = await DebitSetPrice(viewer, series, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
@@ -332,52 +350,58 @@ public class LeaderSkinController : SVSimController
|
||||
return false;
|
||||
}
|
||||
|
||||
private (RewardListEntry? PostState, string? Error) DebitProductPrice(
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
|
||||
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
|
||||
{
|
||||
return salesType switch
|
||||
switch (salesType)
|
||||
{
|
||||
0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0 => (null, null),
|
||||
0 => (null, "price_not_available_for_currency"),
|
||||
1 => product.SinglePriceCrystal is null
|
||||
? (null, "price_not_available_for_currency")
|
||||
: DebitCrystal(viewer, product.SinglePriceCrystal.Value),
|
||||
2 => product.SinglePriceRupy is null
|
||||
? (null, "price_not_available_for_currency")
|
||||
: DebitRupy(viewer, product.SinglePriceRupy.Value),
|
||||
_ => (null, "invalid_sales_type"),
|
||||
};
|
||||
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
|
||||
case 2:
|
||||
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private (RewardListEntry? PostState, string? Error) DebitSetPrice(
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
|
||||
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
|
||||
{
|
||||
return salesType switch
|
||||
switch (salesType)
|
||||
{
|
||||
0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0 => (null, null),
|
||||
0 => (null, "price_not_available_for_currency"),
|
||||
1 => series.SetPriceCrystal is null
|
||||
? (null, "price_not_available_for_currency")
|
||||
: DebitCrystal(viewer, series.SetPriceCrystal.Value),
|
||||
2 => series.SetPriceRupy is null
|
||||
? (null, "price_not_available_for_currency")
|
||||
: DebitRupy(viewer, series.SetPriceRupy.Value),
|
||||
_ => (null, "invalid_sales_type"),
|
||||
};
|
||||
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
|
||||
case 2:
|
||||
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, series.SetPriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private static (RewardListEntry?, string?) DebitCrystal(Viewer viewer, int amount)
|
||||
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
|
||||
{
|
||||
if (viewer.Currency.Crystals < (ulong)amount) return (null, "insufficient_crystals");
|
||||
viewer.Currency.Crystals -= (ulong)amount;
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
|
||||
if (!r.Success) return (null, "insufficient_crystals");
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private static (RewardListEntry?, string?) DebitRupy(Viewer viewer, int amount)
|
||||
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
|
||||
{
|
||||
if (viewer.Currency.Rupees < (ulong)amount) return (null, "insufficient_rupees");
|
||||
viewer.Currency.Rupees -= (ulong)amount;
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
|
||||
if (!r.Success) return (null, "insufficient_rupees");
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private async Task ApplyRewardsAsync<T>(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
|
||||
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
@@ -42,30 +41,27 @@ public class LoadController : SVSimController
|
||||
};
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly ICardRepository _cardRepository;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly IBattlePassService _battlePass;
|
||||
private readonly IViewerMissionStateService _missionState;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
|
||||
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
||||
ICardAcquisitionService acquisition, IGameConfigService config,
|
||||
IBattlePassService battlePass, IViewerMissionStateService missionState,
|
||||
SVSimDbContext db)
|
||||
SVSimDbContext db, IViewerEntitlements entitlements)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_cardRepository = cardRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_acquisition = acquisition;
|
||||
_config = config;
|
||||
_battlePass = battlePass;
|
||||
_missionState = missionState;
|
||||
_db = db;
|
||||
_entitlements = entitlements;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -127,20 +123,11 @@ public class LoadController : SVSimController
|
||||
// * card_set_id=90000 (engine tokens, char_type=4): never collectible
|
||||
// Both naturally fall out of "ownership-only" since the viewer can't own them;
|
||||
// re-confirm the filter if we later move to Option B and start iterating card-sets.
|
||||
var defaultCards = await _cardRepository.GetDefaultCards();
|
||||
var defaultCardIds = defaultCards.Select(c => c.Id).ToHashSet();
|
||||
var ownedCollectibles = viewer.Cards
|
||||
.Where(c => c.Count > 0 && !defaultCardIds.Contains(c.Card.Id));
|
||||
var allCardsAsOwned = ownedCollectibles
|
||||
.Concat(defaultCards.Select(bc => new OwnedCardEntry
|
||||
{
|
||||
Card = bc,
|
||||
Count = 3,
|
||||
IsProtected = true
|
||||
}))
|
||||
.ToList();
|
||||
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
|
||||
// service so both modes share one definition.
|
||||
var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
|
||||
|
||||
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
|
||||
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
|
||||
var classExpCurve = await _globalsRepository.GetClassExpCurve();
|
||||
|
||||
List<ClassExp> classExps = new();
|
||||
@@ -179,7 +166,13 @@ public class LoadController : SVSimController
|
||||
{
|
||||
UserTutorial = new UserTutorial { TutorialStep = viewer.MissionData.TutorialState },
|
||||
UserInfo = new UserInfo(deviceType, viewer),
|
||||
UserCurrency = new UserCurrency(viewer),
|
||||
UserCurrency = new UserCurrency(viewer)
|
||||
{
|
||||
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
|
||||
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee),
|
||||
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther),
|
||||
},
|
||||
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
|
||||
SpotPoint = checked((int)viewer.Currency.SpotPoints),
|
||||
UserRotationDecks = new UserFormatDeckInfo
|
||||
@@ -199,13 +192,13 @@ public class LoadController : SVSimController
|
||||
},
|
||||
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
|
||||
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
|
||||
Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(),
|
||||
UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(),
|
||||
UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(),
|
||||
LeaderSkins = allLeaderSkins
|
||||
.Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id)))
|
||||
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(),
|
||||
LeaderSkins = cosmetics.AllLeaderSkins
|
||||
.Select(skin => new UserLeaderSkin(skin, cosmetics.OwnedLeaderSkinIds.Contains(skin.Id)))
|
||||
.ToList(),
|
||||
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
|
||||
MyPageBackgrounds = cosmetics.MyPageBackgroundIds.Select(id => id.ToString()).ToList(),
|
||||
LootBoxRegulations = new LootBoxRegulations(),
|
||||
GatheringInfo = new GatheringInfo(),
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
@@ -252,7 +245,7 @@ public class LoadController : SVSimController
|
||||
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
|
||||
},
|
||||
ArenaInfos = await BuildArenaInfosAsync(),
|
||||
ArenaInfos = await BuildArenaInfosAsync(viewer.Id),
|
||||
RotationSets = rotationSets,
|
||||
UserConfig = new UserConfig(),
|
||||
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
|
||||
@@ -271,7 +264,7 @@ public class LoadController : SVSimController
|
||||
/// field is omitted on the wire, which the client's <c>Keys.Contains("arena_info")</c> guard
|
||||
/// (LoadDetail.cs:261) handles cleanly.
|
||||
/// </summary>
|
||||
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync()
|
||||
private async Task<List<ArenaInfo>?> BuildArenaInfosAsync(long viewerId)
|
||||
{
|
||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
||||
if (season is null) return null;
|
||||
@@ -282,6 +275,15 @@ public class LoadController : SVSimController
|
||||
format = JsonSerializer.Deserialize<ArenaFormatInfo>(season.FormatInfo, JsonbReadOptions.Instance);
|
||||
}
|
||||
|
||||
// is_join must reflect the viewer's actual TK2 state — true if they have an
|
||||
// active ViewerArenaTwoPickRun row. The client uses this to decide between the
|
||||
// "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase).
|
||||
// Without a per-viewer override here, every cold start after a partial run shows
|
||||
// "Pay to enter" — losing the in-progress draft from the player's perspective.
|
||||
bool hasActiveRun = await _db.ViewerArenaTwoPickRuns
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r => r.ViewerId == viewerId);
|
||||
|
||||
return new List<ArenaInfo>
|
||||
{
|
||||
new ArenaInfo
|
||||
@@ -291,7 +293,7 @@ public class LoadController : SVSimController
|
||||
Cost = season.Cost,
|
||||
RupeeCost = season.RupyCost,
|
||||
TicketCost = season.TicketCost,
|
||||
IsJoin = season.IsJoin,
|
||||
IsJoin = hasActiveRun,
|
||||
FormatInfo = format,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,12 +23,15 @@ public class MyPageController : SVSimController
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly IArenaTwoPickRunRepository _arenaTwoPickRuns;
|
||||
|
||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config)
|
||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
|
||||
IGameConfigService config, IArenaTwoPickRunRepository arenaTwoPickRuns)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_config = config;
|
||||
_arenaTwoPickRuns = arenaTwoPickRuns;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -69,7 +72,7 @@ public class MyPageController : SVSimController
|
||||
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
|
||||
LastAnnounceUpdateTime = string.Empty, // TODO(mypage-stub): globals announcement metadata
|
||||
FeatureMaintenanceList = new(), // TODO(mypage-stub): FeatureMaintenanceEntry rows
|
||||
ArenaInfo = await BuildArenaInfosAsync(),
|
||||
ArenaInfo = await BuildArenaInfosAsync(viewer.Id),
|
||||
IsArenaChallengePeriod = false, // TODO(mypage-stub): globals/ArenaSeason flag
|
||||
IsAvailableColosseumFreeEntry = false, // TODO(mypage-stub): viewer + globals free-entry quota
|
||||
ColosseumInfo = BuildColosseumInfo(colosseum),
|
||||
@@ -155,9 +158,16 @@ public class MyPageController : SVSimController
|
||||
/// _twoPickData.ChallengeData which is only built when arena_info[0].format_info is present.
|
||||
/// So we always populate format_info from the same ArenaSeason.FormatInfo jsonb /load/index uses.
|
||||
/// </summary>
|
||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync()
|
||||
private async Task<List<ArenaInfo>> BuildArenaInfosAsync(long viewerId)
|
||||
{
|
||||
var season = await _globalsRepository.GetCurrentArenaSeason();
|
||||
|
||||
// is_join MUST reflect the viewer's actual TK2 state — true iff they have an
|
||||
// active ViewerArenaTwoPickRun row. The client uses this to choose between the
|
||||
// "Pay to enter" and "Resume run" dialogs (Wizard/ChallengeEntry.cs:165 + ArenaEntryBase).
|
||||
// See LoadController.BuildArenaInfosAsync for the matching /load/index path.
|
||||
bool hasActiveRun = (await _arenaTwoPickRuns.GetByViewerIdAsync(viewerId)) is not null;
|
||||
|
||||
if (season is null)
|
||||
{
|
||||
return new List<ArenaInfo>
|
||||
@@ -169,7 +179,7 @@ public class MyPageController : SVSimController
|
||||
Cost = 0,
|
||||
RupeeCost = 0,
|
||||
TicketCost = 0,
|
||||
IsJoin = false,
|
||||
IsJoin = hasActiveRun,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -189,7 +199,7 @@ public class MyPageController : SVSimController
|
||||
Cost = season.Cost,
|
||||
RupeeCost = season.RupyCost,
|
||||
TicketCost = season.TicketCost,
|
||||
IsJoin = season.IsJoin,
|
||||
IsJoin = hasActiveRun,
|
||||
FormatInfo = format,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,10 +5,12 @@ using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.Database.Repositories.PackDrawTables;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
@@ -24,25 +26,37 @@ public class PackController : SVSimController
|
||||
|
||||
private readonly IPackRepository _packs;
|
||||
private readonly PackOpenService _opener;
|
||||
private readonly ICardPoolProvider _pools;
|
||||
private readonly IPackDrawTableRepository _drawTables;
|
||||
private readonly ICardFoilLookup _foils;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
private readonly IGachaPointService _gachaPoint;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
PackOpenService opener,
|
||||
ICardPoolProvider pools,
|
||||
IPackDrawTableRepository drawTables,
|
||||
ICardFoilLookup foils,
|
||||
IRandom rng,
|
||||
SVSimDbContext db,
|
||||
ICardAcquisitionService acquisition)
|
||||
ICardAcquisitionService acquisition,
|
||||
IGachaPointService gachaPoint,
|
||||
ICurrencySpendService spend,
|
||||
IViewerEntitlements entitlements)
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
_pools = pools;
|
||||
_drawTables = drawTables;
|
||||
_foils = foils;
|
||||
_rng = rng;
|
||||
_db = db;
|
||||
_acquisition = acquisition;
|
||||
_gachaPoint = gachaPoint;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -77,18 +91,48 @@ public class PackController : SVSimController
|
||||
.Select(i => new { ItemId = (long)EF.Property<int>(i, "ItemId"), i.Count })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
|
||||
|
||||
var gachaPointBalancesByPackId = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.GachaPointBalances)
|
||||
.Select(b => new { b.PackId, b.Points })
|
||||
.ToDictionaryAsync(x => x.PackId, x => x.Points);
|
||||
|
||||
return new PackInfoResponse
|
||||
{
|
||||
PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(),
|
||||
PackConfigList = packs
|
||||
.Select(p => ToDto(p, openCounts, ownedItemsByItemId, gachaPointBalancesByPackId))
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static PackConfigDto ToDto(
|
||||
PackConfigEntry p,
|
||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||
IReadOnlyDictionary<long, int> ownedItemsByItemId)
|
||||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||||
IReadOnlyDictionary<int, int> gachaPointBalancesByPackId)
|
||||
{
|
||||
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
|
||||
|
||||
// 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);
|
||||
|
||||
PackGachaPointDto? gachaPointDto = null;
|
||||
if (p.GachaPointConfig is not null && !isTicketOnly)
|
||||
{
|
||||
int balance = gachaPointBalancesByPackId.TryGetValue(p.Id, out var b) ? b : 0;
|
||||
int threshold = p.GachaPointConfig.ExchangeablePoint;
|
||||
gachaPointDto = new PackGachaPointDto
|
||||
{
|
||||
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
|
||||
GachaPoint = balance,
|
||||
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
|
||||
ExchangeableGachaPoint = threshold,
|
||||
IsExchangeableGachaPoint = balance >= threshold,
|
||||
};
|
||||
}
|
||||
|
||||
return new PackConfigDto
|
||||
{
|
||||
ParentGachaId = p.Id,
|
||||
@@ -130,14 +174,7 @@ public class PackController : SVSimController
|
||||
OpenCountLimit = p.OpenCountLimit,
|
||||
IsHide = p.IsHide ? 1 : 0,
|
||||
PackCategory = (int)p.PackCategory,
|
||||
GachaPoint = p.GachaPointConfig is null ? null : new PackGachaPointDto
|
||||
{
|
||||
PackId = p.BasePackId.ToString(CultureInfo.InvariantCulture),
|
||||
GachaPoint = 0,
|
||||
IncreaseGachaPoint = p.GachaPointConfig.IncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
|
||||
ExchangeableGachaPoint = p.GachaPointConfig.ExchangeablePoint,
|
||||
IsExchangeableGachaPoint = false,
|
||||
},
|
||||
GachaPoint = gachaPointDto,
|
||||
IsPreRelease = p.IsPreRelease,
|
||||
ExistsPurchaseReward = false,
|
||||
IsNew = p.IsNew,
|
||||
@@ -146,6 +183,57 @@ public class PackController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("get_gacha_point_rewards")]
|
||||
public async Task<ActionResult<GetGachaPointRewardsResponse>> GetGachaPointRewards(
|
||||
GetGachaPointRewardsRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// odds_gacha_id is the active seasonal pack id (the one with GachaPointConfig +
|
||||
// balance). parent_gacha_id is the base_pack_id of the family — not the lookup key.
|
||||
// See GetGachaPointRewardsRequest docstring; verified against
|
||||
// traffic_prod_all_gacha_exchange.ndjson.
|
||||
var rewards = await _gachaPoint.GetRewardsAsync(request.OddsGachaId, viewerId);
|
||||
|
||||
return new GetGachaPointRewardsResponse
|
||||
{
|
||||
GachaPointRewards = rewards.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("exchange_gacha_point")]
|
||||
public async Task<ActionResult<ExchangeGachaPointResponse>> ExchangeGachaPoint(
|
||||
ExchangeGachaPointRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// Load the viewer with the collections the service mutates (balances, received marker,
|
||||
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.GachaPointReceived)
|
||||
.Include(v => v.Cards)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
|
||||
// live. Mirrors the GetGachaPointRewards fix.
|
||||
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId);
|
||||
if (!outcome.Success) return BadRequest(new { error = outcome.Error });
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ExchangeGachaPointResponse
|
||||
{
|
||||
RewardList = outcome.RewardList.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("open")]
|
||||
[HttpPost("/tutorial/pack_open")]
|
||||
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
|
||||
@@ -189,15 +277,19 @@ 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 currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
|
||||
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path.
|
||||
// The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack
|
||||
// is a free server-granted bonus, not a purchasable pack.
|
||||
if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7))
|
||||
// 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))
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.PackOpenCounts)
|
||||
.Include(v => v.GachaPointBalances)
|
||||
.Include(v => v.MissionData)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery()
|
||||
@@ -218,20 +310,20 @@ public class PackController : SVSimController
|
||||
{
|
||||
switch (child.TypeDetail)
|
||||
{
|
||||
case 2: // CRYSTAL_MULTI
|
||||
case 1: // CRYSTAL (single)
|
||||
case 2: // CRYSTAL_MULTI (10-pack)
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Crystals < cost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= cost;
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
break;
|
||||
}
|
||||
case 7: // RUPY_MULTI
|
||||
case 6: // RUPY (single)
|
||||
case 7: // RUPY_MULTI (10-pack)
|
||||
{
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= cost;
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case 3: // DAILY single — once per UTC day
|
||||
@@ -243,10 +335,23 @@ public class PackController : SVSimController
|
||||
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
|
||||
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||
|
||||
ulong cost = (ulong)child.Cost * (ulong)packNumber;
|
||||
if (cost > 0 && viewer.Currency.Rupees < cost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
if (cost > 0) viewer.Currency.Rupees -= cost;
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case 4: // TICKET (single)
|
||||
case 5: // TICKET_MULTI (10-pack)
|
||||
{
|
||||
if (child.ItemId is not long ticketItemId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
|
||||
|
||||
int ticketsNeeded = child.Cost * packNumber;
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
|
||||
if (owned is null || owned.Count < ticketsNeeded)
|
||||
return BadRequest(new { error = "insufficient_tickets" });
|
||||
|
||||
owned.Count -= ticketsNeeded;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -267,9 +372,37 @@ public class PackController : SVSimController
|
||||
|
||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
||||
int drawCount = child.IsDailySingle ? 1 : packNumber;
|
||||
var draw = _opener.Draw(pack, _pools, drawCount, request.ExcludeCardIds, _rng);
|
||||
|
||||
var drawTable = await _drawTables.GetAsync(pack.Id);
|
||||
if (drawTable is null)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "pack_draw_table_missing" });
|
||||
|
||||
// Owned card_ids for the rate-less Guaranteed-Leader-Card branch. Project to longs to
|
||||
// avoid pulling viewer.Cards entities into memory. Shadow-FK access (EF.Property) per
|
||||
// the project_ef_nav_include_pitfall memory.
|
||||
var ownedCardIds = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Cards)
|
||||
.Select(c => (long)EF.Property<int>(c, "CardId"))
|
||||
.ToListAsync();
|
||||
|
||||
var draw = _opener.Draw(
|
||||
drawTable,
|
||||
pack,
|
||||
drawCount,
|
||||
request.ExcludeCardIds ?? Array.Empty<long>(),
|
||||
ownedCardIds,
|
||||
_foils,
|
||||
_rng);
|
||||
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
|
||||
|
||||
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
|
||||
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
|
||||
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
|
||||
@@ -280,14 +413,25 @@ public class PackController : SVSimController
|
||||
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
if (child.TypeDetail == 2)
|
||||
if (child.TypeDetail is 1 or 2)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
|
||||
}
|
||||
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
|
||||
else if (child.TypeDetail is 3 or 6 or 7)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
|
||||
}
|
||||
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
|
||||
{
|
||||
// Item post-state count for the ticket we just consumed — client direct-assigns
|
||||
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = 4, // Item
|
||||
RewardId = ticketItemId,
|
||||
RewardNum = owned?.Count ?? 0, // post-state total
|
||||
});
|
||||
}
|
||||
}
|
||||
rewardList.AddRange(grant.RewardList);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -13,18 +13,18 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
public class PracticeController : SVSimController
|
||||
{
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
private readonly IDeckListBuilder _deckListBuilder;
|
||||
|
||||
public PracticeController(
|
||||
IDeckRepository deckRepository,
|
||||
IGlobalsRepository globalsRepository,
|
||||
IMissionProgressService missionProgress)
|
||||
IMissionProgressService missionProgress,
|
||||
IDeckListBuilder deckListBuilder)
|
||||
{
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_missionProgress = missionProgress;
|
||||
_deckListBuilder = deckListBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,25 +53,19 @@ public class PracticeController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
|
||||
/// per spec, server can ignore the request field). Fetched via IDeckRepository so the
|
||||
/// DeckCard.Card navigation is Included; going through the heavier viewer-graph query
|
||||
/// drops that ThenInclude and ships 40 zeros instead of real card ids, which then
|
||||
/// NREs the client's SBattleLoad.InitPlayer (CardCreator returns null on id=0).
|
||||
/// /practice/deck_list — same wire shape as /deck/info (the client parses both via
|
||||
/// DeckGroupListData), so it shares <see cref="IDeckListBuilder"/>. Always All-format per spec.
|
||||
/// Unlike /deck/info this is a deck *select* screen, so empty "New Deck" slots are NOT padded
|
||||
/// (padEmptySlots: false) — prod's practice capture returns the viewer's real decks unpadded,
|
||||
/// plus the 8 per-class default decks and per-class leader-skin settings. The builder loads
|
||||
/// decks via IDeckRepository (DeckCard.Card Included), so card_id_array carries real ids rather
|
||||
/// than the 40 zeros that NRE the client's SBattleLoad.InitPlayer.
|
||||
/// </summary>
|
||||
[HttpPost("deck_list")]
|
||||
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
|
||||
public async Task<ActionResult<DeckListResponse>> DeckList(DeckFormatRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited });
|
||||
|
||||
return new PracticeDeckListResponse
|
||||
{
|
||||
MaintenanceCardList = new List<long>(),
|
||||
UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(),
|
||||
UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(),
|
||||
};
|
||||
return await _deckListBuilder.BuildAsync(viewerId, Format.All, padEmptySlots: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
@@ -20,11 +21,17 @@ public class SleeveController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public SleeveController(SVSimDbContext db, RewardGrantService rewards)
|
||||
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -35,10 +42,12 @@ public class SleeveController : SVSimController
|
||||
// is_purchased_product is "viewer owns at least one sleeve granted by this product".
|
||||
// Loading the viewer's sleeve-id set once and checking each product against it avoids
|
||||
// an N+1 over products.
|
||||
var ownedSleeveIds = (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
var ownedSleeveIds = _entitlements.IsFreeplay
|
||||
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
|
||||
: (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var series = await _db.SleeveShopSeries
|
||||
.Where(s => s.IsEnabled)
|
||||
@@ -106,6 +115,9 @@ public class SleeveController : SVSimController
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
@@ -122,20 +134,16 @@ public class SleeveController : SVSimController
|
||||
case 1: // crystal
|
||||
if (product.PriceCrystal is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
var crystalCost = (ulong)product.PriceCrystal.Value;
|
||||
if (viewer.Currency.Crystals < crystalCost)
|
||||
return BadRequest(new { error = "insufficient_crystals" });
|
||||
viewer.Currency.Crystals -= crystalCost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals });
|
||||
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
|
||||
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
|
||||
break;
|
||||
case 2: // rupy
|
||||
if (product.PriceRupy is null)
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
var rupyCost = (ulong)product.PriceRupy.Value;
|
||||
if (viewer.Currency.Rupees < rupyCost)
|
||||
return BadRequest(new { error = "insufficient_rupees" });
|
||||
viewer.Currency.Rupees -= rupyCost;
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees });
|
||||
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
|
||||
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ public class SpotCardExchangeController : SVSimController
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
|
||||
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
}
|
||||
|
||||
[HttpPost("top")]
|
||||
@@ -131,14 +133,14 @@ public class SpotCardExchangeController : SVSimController
|
||||
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
|
||||
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
|
||||
// first, then grants.
|
||||
if (viewer.Currency.SpotPoints < (ulong)entry.ExchangePoint)
|
||||
var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint);
|
||||
if (!spotRes.Success)
|
||||
return BadRequest(new { error = "insufficient_spot_points" });
|
||||
viewer.Currency.SpotPoints -= (ulong)entry.ExchangePoint;
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = (int)UserGoodsType.SpotCardPoint,
|
||||
RewardId = 0,
|
||||
RewardNum = checked((int)viewer.Currency.SpotPoints),
|
||||
RewardNum = checked((int)spotRes.PostStateTotal),
|
||||
});
|
||||
|
||||
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
|
||||
|
||||
@@ -234,7 +234,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
|
||||
// Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous
|
||||
// envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape
|
||||
// captured in data_dumps/traffic_prod_deckcode.ndjson.
|
||||
// captured in data_dumps/captures/traffic_prod_deckcode.ndjson.
|
||||
DataWrapper wrappedResponseData = new DataWrapper
|
||||
{
|
||||
Data = responseData,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
|
||||
|
||||
[MessagePackObject]
|
||||
public class BattleResultsDto
|
||||
{
|
||||
/// <summary>Each entry is 0 (loss) or 1 (win). Native int array — matches capture.</summary>
|
||||
[JsonPropertyName("result_list")] [Key("result_list")]
|
||||
public List<int> ResultList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("win_count")] [Key("win_count")]
|
||||
public int WinCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
|
||||
|
||||
[MessagePackObject]
|
||||
public class CandidatePairDto
|
||||
{
|
||||
[JsonPropertyName("id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("turn")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("turn")]
|
||||
public int Turn { get; set; }
|
||||
|
||||
[JsonPropertyName("set_num")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("set_num")]
|
||||
public int SetNum { get; set; }
|
||||
|
||||
[JsonPropertyName("card_id_1")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("card_id_1")]
|
||||
public long CardId1 { get; set; }
|
||||
|
||||
[JsonPropertyName("card_id_2")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("card_id_2")]
|
||||
public long CardId2 { get; set; }
|
||||
|
||||
[JsonPropertyName("is_selected")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("is_selected")]
|
||||
public int IsSelected { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user