diff --git a/src/CapySR.Data/Excel/ChallengeRows.cs b/src/CapySR.Data/Excel/ChallengeRows.cs index 5777c47..1d8046a 100644 --- a/src/CapySR.Data/Excel/ChallengeRows.cs +++ b/src/CapySR.Data/Excel/ChallengeRows.cs @@ -175,6 +175,13 @@ public sealed class TutorialGuideGroupExcel public uint GroupID { get; set; } } +public sealed class GachaCeilingExcel +{ + public string GachaType { get; set; } = string.Empty; + + public List CeilingItemList { get; set; } = []; +} + // maps an item id to its main/sub type. the client NPEs on an item it cannot type. public sealed class ItemConfigExcel { diff --git a/src/CapySR.Data/GameData.Merge.cs b/src/CapySR.Data/GameData.Merge.cs index 54f436a..d3e4aa9 100644 --- a/src/CapySR.Data/GameData.Merge.cs +++ b/src/CapySR.Data/GameData.Merge.cs @@ -114,4 +114,9 @@ public sealed partial class GameData row.TryGetPropertyValue(name, out var node) && node is not null && node.GetValueKind() is JsonValueKind.Number ? node.GetValue() : null; + + private static string? Str(JsonObject row, string name) => + row.TryGetPropertyValue(name, out var node) && node is not null && node.GetValueKind() is JsonValueKind.String + ? node.GetValue() + : null; } diff --git a/src/CapySR.Data/GameData.cs b/src/CapySR.Data/GameData.cs index f0d80fd..d2210d3 100644 --- a/src/CapySR.Data/GameData.cs +++ b/src/CapySR.Data/GameData.cs @@ -120,6 +120,9 @@ public sealed partial class GameData public IReadOnlyList TutorialGuideIds { get; private set; } = []; + // the standard banner's guaranteed 5* pool + public IReadOnlyList GachaCeilingAvatars { get; private set; } = []; + // every relic / lightcone id the client can resolve a type for public IReadOnlySet KnownRelicIds { get; private set; } = new HashSet(); @@ -207,6 +210,9 @@ public sealed partial class GameData data.MainMissions = data.Merge("MainMission.json", r => UInt(r, "MainMissionID"), required: false); data.TutorialIds = [.. data.Merge("TutorialData.json", r => UInt(r, "TutorialID"), required: false).Keys]; data.TutorialGuideIds = [.. data.Merge("TutorialGuideGroup.json", r => UInt(r, "GroupID"), required: false).Keys]; + data.GachaCeilingAvatars = [.. data + .Merge("GachaCeiling.json", r => Str(r, "GachaType") == "Normal" ? 0u : null, required: false) + .Values.SelectMany(r => r.CeilingItemList)]; data.KnownRelicIds = data.Merge("ItemConfigRelic.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet(); data.KnownLightconeIds = data.Merge("ItemConfigEquipment.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet(); diff --git a/src/CapySR.GameServer/Handlers/MiscHandlers.cs b/src/CapySR.GameServer/Handlers/MiscHandlers.cs index b665473..1e0e72e 100644 --- a/src/CapySR.GameServer/Handlers/MiscHandlers.cs +++ b/src/CapySR.GameServer/Handlers/MiscHandlers.cs @@ -8,6 +8,8 @@ namespace CapySR.GameServer.Handlers; [Handlers] public static class MiscHandlers { + private const uint StandardBannerId = 1001; + // client settings the game stores server side (auto battle, speed, ...) public static Task OnGetAllServerPrefsData(PlayerSession session, GetAllServerPrefsDataCsReq request) { @@ -63,8 +65,29 @@ public static class MiscHandlers public static Task OnGetVideoVersionKey(PlayerSession session, GetVideoVersionKeyCsReq request) => session.SendAsync(new GetVideoVersionKeyScRsp { Retcode = 0 }); - public static Task OnGetGachaInfo(PlayerSession session, GetGachaInfoCsReq request) => - session.SendAsync(new GetGachaInfoScRsp { Retcode = 0 }); + // an empty list leaves the gacha red-dot filter indexing nil every UI tick, which throws + // out of UIManager.Tick every frame. hand it the standard banner and nothing else. + public static Task OnGetGachaInfo(PlayerSession session, GetGachaInfoCsReq request) + { + var ceiling = new GachaCeiling { CeilingNum = 300, IsClaimed = false }; + + foreach (var avatarId in session.World.Data.GachaCeilingAvatars) + { + ceiling.AvatarList.Add(new GachaCeilingAvatar { AvatarId = avatarId, RepeatedCnt = 300 }); + } + + var response = new GetGachaInfoScRsp { Retcode = 0, GachaRandom = 0 }; + + response.GachaInfoList.Add(new GachaInfo + { + GachaId = StandardBannerId, + GachaCeiling = ceiling, + BeginTime = 0, + EndTime = 2524608000, + }); + + return session.SendAsync(response); + } public static Task OnGetDailyActiveInfo(PlayerSession session, GetDailyActiveInfoCsReq request) => session.SendAsync(new GetDailyActiveInfoScRsp { Retcode = 0 }); diff --git a/tests/CapySR.Tests/GameDataTests.cs b/tests/CapySR.Tests/GameDataTests.cs index 9c62a3a..8278695 100644 --- a/tests/CapySR.Tests/GameDataTests.cs +++ b/tests/CapySR.Tests/GameDataTests.cs @@ -36,6 +36,20 @@ public class GameDataTests(ITestOutputHelper output) Assert.NotEmpty(data.Equipment); } + // an empty banner leaves the client's gacha red dot throwing once a frame + [Fact] + public void ReadsStandardBannerCeiling() + { + var data = TryLoad(output); + if (data is null) + { + return; + } + + Assert.NotEmpty(data.GachaCeilingAvatars); + Assert.Contains(1003u, data.GachaCeilingAvatars); + } + [Fact] public void ParsesMarch7th() {