diff --git a/AscNet.Common/Database/Player.Draw.cs b/AscNet.Common/Database/Player.Draw.cs index 9bddc923..122986a9 100644 --- a/AscNet.Common/Database/Player.Draw.cs +++ b/AscNet.Common/Database/Player.Draw.cs @@ -47,6 +47,10 @@ public class PlayerMemberTargetPity public class PlayerDrawState { + [BsonElement("pity_rounds")] + [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)] + public Dictionary PityRounds { get; set; } = new(); + [BsonElement("member_target_calibration_target_id")] public int MemberTargetCalibrationTargetId { get; set; } @@ -77,6 +81,15 @@ public class PlayerDrawState public Dictionary HistoryByGroup { get; set; } = new(); } + public class PlayerDrawPityRound + { + public int Misses { get; set; } + public int Limit { get; set; } + public bool HasObtainedRare { get; set; } + public bool GuaranteedTarget { get; set; } + public int LowerMisses { get; set; } + } + public partial class Player { [BsonElement("draw_state")] diff --git a/AscNet.GameServer/Game/DrawManager.Rules.cs b/AscNet.GameServer/Game/DrawManager.Rules.cs new file mode 100644 index 00000000..4c15ca9b --- /dev/null +++ b/AscNet.GameServer/Game/DrawManager.Rules.cs @@ -0,0 +1,268 @@ +using AscNet.Common.Database; +using AscNet.Common.MsgPack; +using AscNet.Common.Util; +using AscNet.GameServer.Handlers; +using AscNet.Table.V2.client.draw; +using AscNet.Table.V2.share.draw; +using System.Globalization; + +namespace AscNet.GameServer.Game; + +internal static partial class DrawManager +{ + // Inheritance and guarantee rules transcribed from client DrawGroupRule. See + // Resources/Configs/draw-rules-source.md for version-dependent decisions. + private static readonly Dictionary Rules = + TableReaderV2.Parse().ToDictionary(x => x.GroupId); + + private static readonly Dictionary InitialCharacterQuality = CharacterQualities + .GroupBy(x => x.CharacterId).ToDictionary(x => x.Key, x => x.Min(q => q.Quality)); + private static readonly Dictionary PreviewPools = DrawPreviews.ToDictionary(x => x.Id, + x => x.GoodsId.Concat(x.UpGoodsId).Where(id => id > 0).Distinct().ToArray()); + private static readonly Dictionary BannerTargetProbabilities = + TableReaderV2.Parse() + .Where(x => !string.IsNullOrWhiteSpace(x.UpProbabilityPercent)) + .ToDictionary(x => x.Id, x => Probability(x.UpProbabilityPercent!)); + private static readonly Lazy> VariablePityDistributions = new(BuildVariablePityDistributions); + + private static DrawServerRuleTable Rule(DrawInfo draw) => Rules.TryGetValue(draw.GroupId, out var rule) + ? rule : throw new InvalidDataException($"Missing draw rules for group {draw.GroupId}"); + + private static double Probability(string value) => double.Parse(value.Trim().TrimEnd('%'), CultureInfo.InvariantCulture) / 100d; + + private static double RareProbability(DrawInfo draw) + { + var profile = DrawProbShowsById[draw.Id]; + int baseIndex = profile.Name.FindIndex(name => name.Contains("Base Drop", StringComparison.OrdinalIgnoreCase)); + if (baseIndex >= 0) return Probability(profile.ProbShow[baseIndex]); + if (RewardKind(draw) == 2) + return profile.Name.Select((name, i) => name.Replace(" ", "").Contains("6★") ? Probability(profile.ProbShow[i]) : 0).Sum(); + throw new InvalidDataException($"Missing base probability for draw {draw.Id}"); + } + + private static PlayerDrawPityRound GetPityRound(Player player, DrawInfo draw, Random random) + { + EnsureState(player); + var rule = Rule(draw); + if (player.DrawState.PityRounds.TryGetValue(rule.PityGroupId, out var round)) return round; + + // The old counter was lifetime progress. Preserve its current partial cycle + // when upgrading, then maintain a separate counter since the last rare result. + int oldCount = GetPityCount(player, draw.GroupId); + round = new() { HasObtainedRare = oldCount >= rule.FirstPity && oldCount > 0 }; + round.Limit = NextLimit(draw, rule, round, random); + round.Misses = oldCount % (oldCount < rule.FirstPity ? rule.FirstPity : rule.PityMax); + // A sampled Fate limit below the inherited progress means the next pull is due. + player.DrawState.PityRounds[rule.PityGroupId] = round; + return round; + } + + public static bool InitializePityState(Player player, int groupId = 0) + { + EnsureState(player); + int before = player.DrawState.PityRounds.Count; + IEnumerable draws = groupId == 0 + ? DrawTemplates.Where(IsActive) + : DrawsByGroup.GetValueOrDefault(groupId, []).Where(IsActive); + foreach (DrawInfo draw in draws.Where(x => x.GroupId != 1 && Rules.ContainsKey(x.GroupId)) + .GroupBy(x => Rule(x).PityGroupId).Select(x => x.First())) + GetPityRound(player, draw, Random.Shared); + return player.DrawState.PityRounds.Count != before; + } + + private static int NextLimit(DrawInfo draw, DrawServerRuleTable rule, PlayerDrawPityRound round, Random random) + { + if (!round.HasObtainedRare && rule.FirstPity > 0) return rule.FirstPity; + if (rule.PityMin == rule.PityMax) return rule.PityMin; + double roll = random.NextDouble(); + double cumulative = 0; + double[] weights = VariablePityDistributions.Value[rule.GroupId]; + for (int i = 0; i < weights.Length; i++) + { + cumulative += weights[i]; + if (roll < cumulative) return rule.PityMin + i; + } + return rule.PityMax; + } + + private static Dictionary BuildVariablePityDistributions() + { + Dictionary clientRules = TableReaderV2.Parse().ToDictionary(x => x.Id); + Dictionary result = new(); + foreach (DrawServerRuleTable rule in Rules.Values.Where(x => x.PityMin < x.PityMax && DrawTemplates.Any(d => d.GroupId == x.GroupId))) + { + DrawGroupRuleTable clientRule = clientRules[rule.GroupId]; + string referenceTitle = clientRule.TitleCN.StartsWith("Fate ", StringComparison.Ordinal) + ? clientRule.TitleCN[5..] : throw new InvalidDataException($"Variable pity group {rule.GroupId} has no rate reference"); + DrawGroupRuleTable referenceClientRule = clientRules.Values.Single(x => x.TitleCN == referenceTitle); + DrawServerRuleTable referenceRule = Rules[referenceClientRule.Id]; + DrawInfo draw = DrawTemplates.First(x => x.GroupId == rule.GroupId); + DrawInfo referenceDraw = DrawTemplates.First(x => x.GroupId == referenceRule.GroupId); + double baseRate = RareProbability(draw); + double referenceBaseRate = RareProbability(referenceDraw); + double referenceOverallRate = referenceBaseRate / + (1 - Math.Pow(1 - referenceBaseRate, referenceRule.PityMax)); + double requiredPowerMean = 1 - baseRate / referenceOverallRate; + double missRate = 1 - baseRate; + double minPower = Math.Pow(missRate, rule.PityMax); + double maxPower = Math.Pow(missRate, rule.PityMin); + if (requiredPowerMean < minPower || requiredPowerMean > maxPower) + throw new InvalidDataException($"Variable pity group {rule.GroupId} cannot match group {referenceRule.GroupId} overall rate"); + + // The client specifies the integer range and combined rate, but not server weights. + // Maximum entropy supplies the least-assumptive full-support distribution satisfying both constraints. + int count = rule.PityMax - rule.PityMin + 1; + double low = -32, high = 32; + for (int iteration = 0; iteration < 100; iteration++) + { + double slope = (low + high) / 2; + double mean = ExponentialPowerMean(rule.PityMin, count, missRate, slope); + if (mean > requiredPowerMean) low = slope; else high = slope; + } + double finalSlope = (low + high) / 2; + double[] weights = Enumerable.Range(0, count).Select(i => Math.Exp(finalSlope * (i - count + 1))).ToArray(); + double total = weights.Sum(); + for (int i = 0; i < weights.Length; i++) weights[i] /= total; + result.Add(rule.GroupId, weights); + } + return result; + } + + private static double ExponentialPowerMean(int minimum, int count, double missRate, double slope) + { + double total = 0, weighted = 0; + for (int i = 0; i < count; i++) + { + double weight = Math.Exp(slope * (i - count + 1)); + total += weight; + weighted += weight * Math.Pow(missRate, minimum + i); + } + return weighted / total; + } + + private static double OverallRareProbability(DrawInfo draw) + { + DrawServerRuleTable rule = Rule(draw); + double baseRate = RareProbability(draw); + double[] weights = rule.PityMin == rule.PityMax ? [1] : VariablePityDistributions.Value[rule.GroupId]; + double missPowerMean = weights.Select((weight, index) => weight * Math.Pow(1 - baseRate, rule.PityMin + index)).Sum(); + return baseRate / (1 - missPowerMean); + } + + private static int[] PreviewIds(DrawInfo draw) + { + return PreviewPools.TryGetValue(draw.Id, out var ids) ? ids + : throw new InvalidDataException($"Missing draw preview {draw.Id}"); + } + + // DrawType controls client presentation; character and CUB banners both use 3. + private static int RewardKind(DrawInfo draw) + { + var profile = DrawProbShowsById[draw.Id]; + if (profile.Name.Any(x => x.Contains("CUB"))) return 3; + if (profile.Name.Any(x => x.Contains("6★"))) return 2; + return 1; + } + private static int[] RarePool(DrawInfo draw) + { + int[] ids = PreviewIds(draw); + return RewardKind(draw) switch + { + 2 => ids.Where(id => Equips.Any(x => x.Id == id && x.Type > 0 && x.Quality == 6 && Character.IsOwnableEquipTemplate(x))).ToArray(), + 3 => ids.Where(id => Partners.Any(x => x.Id == id && x.InitQuality == 3)).ToArray(), + _ => ids.Where(id => InitialCharacterQuality.GetValueOrDefault(id) == 3).ToArray() + }; + } + + private static RewardGoods RollDraw(Player player, DrawInfo draw, Random random) + { + var rule = Rule(draw); + var round = GetPityRound(player, draw, random); + bool rare = round.Misses + 1 >= round.Limit || random.NextDouble() < RareProbability(draw); + RewardGoods reward; + if (rare) + { + int[] pool = RarePool(draw); + int target = draw.ResourceIds.GetValueOrDefault(1); + bool featured = DrawServerCatalog.Any(x => x.Id == draw.Id && x.TargetId == target); + double targetProbability = BannerTargetProbabilities.GetValueOrDefault(draw.Id, + (featured ? rule.FeaturedTargetPercent : rule.TargetPercent) / 100d); + bool hasTarget = targetProbability > 0 && pool.Contains(target); + if (targetProbability > 0 && !hasTarget) + throw new InvalidDataException($"Draw {draw.Id} target {target} is missing from its highest-rarity preview"); + bool hit = hasTarget && (round.GuaranteedTarget || random.NextDouble() < targetProbability); + int[] candidates = hasTarget && !hit ? pool.Where(id => id != target).ToArray() : pool; + if (!hit && candidates.Length == 0) + throw new InvalidDataException($"Draw {draw.Id} has no eligible highest-rarity outcomes"); + int id = hit ? target : candidates[random.Next(candidates.Length)]; + reward = Create(RewardKind(draw) switch { 2 => RewardType.Equip, 3 => RewardType.Partner, _ => RewardType.Character }, id, 1, 1); + round.GuaranteedTarget = rule.Calibration != 0 && hasTarget && id != target; + round.HasObtainedRare = true; + round.Misses = 0; + round.Limit = NextLimit(draw, rule, round, random); + } + else + { + reward = RollNonRare(draw, random, rule.LowerPity > 0 && round.LowerMisses + 1 >= rule.LowerPity); + round.Misses++; + } + bool lowerOrBetter = rare || (RewardKind(draw) switch + { + 2 => reward.RewardType == (int)RewardType.Equip && Equips.Any(x => x.Id == reward.TemplateId && x.Quality >= 5), + 3 => reward.RewardType == (int)RewardType.Partner, + _ => reward.RewardType == (int)RewardType.Character && InitialCharacterQuality.GetValueOrDefault(reward.TemplateId) >= 2 + }); + round.LowerMisses = lowerOrBetter ? 0 : round.LowerMisses + 1; + return reward; + } + + private static RewardGoods RollNonRare(DrawInfo draw, Random random, bool forceLower = false) + { + // Only conditional non-rare weights are used here; S/6-star is rolled once. + var profile = DrawProbShowsById[draw.Id]; + var entries = profile.Name.Select((name, i) => (Name: name.Replace("6 ★", "6★").Replace("5 ★", "5★"), Weight: Probability(profile.ProbShow[i]))) + .Where(x => !x.Name.Contains("S-Rank") && !x.Name.Contains("6★")) + .Where(x => !forceLower || x.Name.Contains("5★") || x.Name.Contains("A-Rank") || x.Name.Contains("A, B-Rank")).ToArray(); + double roll = random.NextDouble() * entries.Sum(x => x.Weight); + string category = entries.Last().Name; + foreach (var entry in entries) + { + roll -= entry.Weight; + if (roll < 0) { category = entry.Name; break; } + } + if (RewardKind(draw) == 2) + { + int quality = category.Contains("5★") ? 5 : category.Contains("4★") ? 4 : category.Contains("3★") ? 3 : 0; + var pool = Equips.Where(x => x.Type > 0 && x.Quality == quality && Character.IsOwnableEquipTemplate(x) + && (category.Contains('】') || category.Contains(']') || quality < 5 || Rule(draw).TargetPercent == 0 || PreviewIds(draw).Contains(x.Id))) + .Where(x => !category.Contains('】') && !category.Contains(']') || category.EndsWith(x.Name, StringComparison.Ordinal)) + .Select(x => x.Id).ToArray(); + if (pool.Length > 0) return Create(RewardType.Equip, pool[random.Next(pool.Length)], 1, 1); + } + if (RewardKind(draw) == 3 && category.Contains("A-Rank")) + { + int[] pool = PreviewIds(draw).Where(id => Partners.Any(x => x.Id == id && x.InitQuality < 3)).ToArray(); + if (pool.Length > 0) return Create(RewardType.Partner, pool[random.Next(pool.Length)], 1, 1); + } + if (category.Contains("A, B-Rank")) + { + int[] pool = PreviewIds(draw).Where(id => InitialCharacterQuality.GetValueOrDefault(id) is 1 or 2) + .Where(id => !forceLower || InitialCharacterQuality[id] >= 2).ToArray(); + if (pool.Length > 0) return Create(RewardType.Character, pool[random.Next(pool.Length)], 1, 1); + } + RewardGoods? reward = category switch + { + "Construct Shard" => DrawCharacterShardReward(draw), + "4★ Equipment" => DrawMemoryReward(), + "Overclock Material" => DrawOverclockMaterialReward(), + "EXP Material" => DrawExpMaterialReward(), + "Cog Box" => DrawCogBoxReward(), + "CUB EXP Material" => DrawItemReward(x => x.Name.StartsWith("Integrated CUB EXP"), 1), + "CUB Overclock Material" => DrawItemReward(x => x.Name.StartsWith("Support Overclock Bundle"), 1), + "Support Skill Component" => DrawItemReward(x => x.Name == category, 1), + "CUB Shard" => DrawItemReward(x => Partners.Any(p => PreviewIds(draw).Contains(p.Id) && p.ChipItemId == x.Id), 1), + _ => null + }; + return reward ?? throw new InvalidDataException($"Draw {draw.Id} has no reward for {category}"); + } +} diff --git a/AscNet.GameServer/Game/DrawManager.cs b/AscNet.GameServer/Game/DrawManager.cs index 1509ce4c..f4300d04 100644 --- a/AscNet.GameServer/Game/DrawManager.cs +++ b/AscNet.GameServer/Game/DrawManager.cs @@ -13,7 +13,7 @@ namespace AscNet.GameServer.Game; -internal static class DrawManager +internal static partial class DrawManager { internal const int CatalogUnavailableCode = 1; private const int MinDrawItemShowQuality = 3; @@ -5119,8 +5119,17 @@ public static List GetDrawGroupInfos(Player player) value.UseDrawIdDict = GetSelections(player, group); value.SwitchDrawIdCount = player.DrawState.SwitchCountByGroup.GetValueOrDefault(group.Id); DrawInfo selected = GetSelected(player, group); - value.BottomTimes = GetPlayerBottomTimes(player, selected); - value.MaxBottomTimes = selected.MaxBottomTimes; + if (group.Id == 1) + { + value.BottomTimes = GetPlayerBottomTimes(player, selected); + value.MaxBottomTimes = selected.MaxBottomTimes; + } + else + { + DrawInfo status = BuildDrawInfo(selected, player); + value.BottomTimes = status.BottomTimes; + value.MaxBottomTimes = status.MaxBottomTimes; + } return value; }).ToList(); } @@ -5131,7 +5140,9 @@ public static (int BottomTimes, int MaxBottomTimes) GetDrawHistoryStatus(Player { if (!GroupsById.TryGetValue(groupId, out DrawGroupInfo? group) || !IsActive(group)) return (0, 0); DrawInfo draw = DrawsByGroup[groupId].FirstOrDefault(x => x.GroupSubType == groupSubType) ?? GetSelected(player, group); - return (GetPlayerBottomTimes(player, draw), draw.MaxBottomTimes); + if (groupId == 1) return (GetPlayerBottomTimes(player, draw), draw.MaxBottomTimes); + DrawInfo status = BuildDrawInfo(draw, player); + return (status.BottomTimes, status.MaxBottomTimes); } public static List<(RewardGoods RewardGoods, long DrawTime)> GetDrawHistory(Player player, int groupId, int groupSubType) @@ -5253,15 +5264,8 @@ public static List DrawDraw(Player player, int drawId, int pullOffs RewardGoods? member = DrawMemberReward(player, draw, Random.Shared.NextDouble(), Random.Shared.NextDouble(), Random.Shared.NextDouble(), Random.Shared.NextDouble()); return member is null ? [] : [member]; } - bool forceRare = draw.MaxBottomTimes > 0 && GetBottomTimes(draw, GetPityCount(player, draw.GroupId) + pullOffset) == 1; - RewardGoods? reward = draw.GroupId switch - { - 2 or 4 => DrawEquipReward(draw, forceRare), - 13 => DrawLegacyCharacterReward(draw, forceRare), - 22 => DrawPartnerReward(draw), - _ => DrawCharacterReward(draw, forceRare) - }; - return reward is null ? [] : [reward]; + // Each result advances its own pity round, including within a ten-pull. + return [RollDraw(player, draw, Random.Shared)]; } private static void EnsureState(Player player) => player.DrawState ??= new(); @@ -5335,7 +5339,15 @@ private static DrawInfo BuildDrawInfo(DrawInfo template, Player player) PlayerDrawProgress progress = GetProgress(player, template.Id); value.TodayCount = progress.TodayCount; value.TotalCount = progress.TotalCount; - value.BottomTimes = GetPlayerBottomTimes(player, template); + if (template.GroupId == 1) + { + value.BottomTimes = GetPlayerBottomTimes(player, template); + return value; + } + PlayerDrawPityRound round = GetPityRound(player, template, Random.Shared); + value.MaxBottomTimes = round.Limit; + value.BottomTimes = Math.Max(1, round.Limit - round.Misses); + value.IsTriggerSpecified = round.GuaranteedTarget; return value; } diff --git a/AscNet.GameServer/Handlers/DrawModule.cs b/AscNet.GameServer/Handlers/DrawModule.cs index 9d7a9960..d9c375b8 100644 --- a/AscNet.GameServer/Handlers/DrawModule.cs +++ b/AscNet.GameServer/Handlers/DrawModule.cs @@ -334,12 +334,14 @@ internal static NotifyDrawCanLiverData BuildNotifyDrawCanLiverData(Player player [RequestPacketHandler("DrawGetDrawGroupListRequest")] public static void DrawGetDrawGroupListRequestHandler(Session session, Packet.Request packet) { + bool initializedPity = DrawManager.InitializePityState(session.player); DrawGetDrawGroupListResponse rsp = new() { DrawGroupInfoList = DrawManager.GetDrawGroupInfos(session.player), DrawAdjustActivityInfoList = DrawManager.GetDrawAdjustActivityInfos(session.player) }; + if (initializedPity) session.player.Save(); session.SendResponse(rsp, packet.Id); } @@ -364,13 +366,14 @@ public static void DrawGetHistoryGroupListRequestHandler(Session session, Packet public static void DrawGroupGetHistoryRequestHandler(Session session, Packet.Request packet) { DrawGroupGetHistoryRequest request = packet.Deserialize(); + bool initializedPity = DrawManager.InitializePityState(session.player, request.GroupId); (int bottomTimes, int maxBottomTimes) = DrawManager.GetDrawHistoryStatus( session.player, request.GroupId, request.GroupSubType ); - session.SendResponse(new DrawGroupGetHistoryResponse + DrawGroupGetHistoryResponse response = new() { HistoryRewardList = DrawManager.GetDrawHistory(session.player, request.GroupId, request.GroupSubType) .Select(entry => new DrawHistoryReward @@ -381,17 +384,21 @@ public static void DrawGroupGetHistoryRequestHandler(Session session, Packet.Req .ToList(), BottomTimes = bottomTimes, MaxBottomTimes = maxBottomTimes - }, packet.Id); + }; + if (initializedPity) session.player.Save(); + session.SendResponse(response, packet.Id); } [RequestPacketHandler("DrawGetDrawInfoListRequest")] public static void DrawGetDrawInfoListRequestHandler(Session session, Packet.Request packet) { DrawGetDrawInfoListRequest request = packet.Deserialize(); + bool initializedPity = DrawManager.InitializePityState(session.player, request.GroupId); DrawGetDrawInfoListResponse rsp = new(); rsp.DrawInfoList.AddRange(DrawManager.GetDrawInfosByGroup(request.GroupId, session.player)); + if (initializedPity) session.player.Save(); session.SendResponse(rsp, packet.Id); } @@ -426,6 +433,9 @@ public static void DrawDrawCardRequestHandler(Session session, Packet.Request pa DrawDrawCardRequest request = packet.Deserialize(); long playerId = session.player.PlayerData.Id; int drawCount = request.Count <= 0 ? 1 : Math.Min(request.Count, 10); + int groupId = DrawManager.GetGroupByDrawId(request.DrawId); + if (groupId > 0 && DrawManager.InitializePityState(session.player, groupId)) + session.player.Save(); DrawInfo? initialDrawInfo = DrawManager.GetDrawInfoById(request.DrawId, session.player); if (initialDrawInfo is null || !DrawManager.HasRewardConfiguration(request.DrawId)) { diff --git a/AscNet.Test/Program.DrawRules.cs b/AscNet.Test/Program.DrawRules.cs new file mode 100644 index 00000000..1aac644a --- /dev/null +++ b/AscNet.Test/Program.DrawRules.cs @@ -0,0 +1,149 @@ +using AscNet.Common.Database; +using AscNet.Common.MsgPack; +using AscNet.GameServer.Handlers; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using System.Reflection; + +namespace AscNet.Test; + +internal partial class Program +{ + private sealed class DrawFixedRandom(double value, bool upper = false) : Random + { + public override double NextDouble() => value; + public override int Next(int maxValue) => upper ? maxValue - 1 : 0; + public override int Next(int minValue, int maxValue) => upper ? maxValue - 1 : minValue; + } + + private static void ValidateDrawRules() + { + Type manager = RequiredAscNetGameServerType("AscNet.GameServer.Game.DrawManager"); + object? Call(string name, params object[] args) => manager.GetMethod(name, + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!.Invoke(null, args); + DrawInfo Template(int group) => Version47CatalogTemplates().First(x => x.GroupId == group); + RewardGoods Roll(Player p, DrawInfo d, Random r) => (RewardGoods)Call("RollDraw", p, d, r)!; + DrawInfo fate = Template(15), theme = Template(11), weapon = Template(4), member = Template(1), cub = Template(22); + foreach (var (d, rate) in new[] { (fate, .015), (theme, .005), (weapon, .05), (member, .005), (cub, .0582), (Template(16), .05) }) + AssertEqual(true, Math.Abs((double)Call("RareProbability", d)! - rate) < 1e-10, $"client probability group {d.GroupId}"); + + Player p = new(); + Call("GetPityRound", p, fate, new DrawFixedRandom(0)); + for (int i = 0; i < 79; i++) Roll(p, fate, new DrawFixedRandom(.999)); + AssertEqual(79, p.DrawState.PityRounds[15].Misses, "Fate misses before lower bound"); + AssertEqual(fate.ResourceIds[1], Roll(p, fate, new DrawFixedRandom(.999)).TemplateId, "Fate guarantee at 80"); + AssertEqual(0, p.DrawState.PityRounds[15].Misses, "S resets pity immediately"); + p = new(); + Call("GetPityRound", p, fate, new DrawFixedRandom(.999, true)); + for (int i = 0; i < 99; i++) Roll(p, fate, new DrawFixedRandom(.999, true)); + AssertEqual(99, p.DrawState.PityRounds[15].Misses, "Fate upper bound accepts 99 misses"); + Roll(p, fate, new DrawFixedRandom(.999, true)); + AssertEqual(0, p.DrawState.PityRounds[15].Misses, "Fate guarantee at 100"); + + p = new(); + Roll(p, fate, new DrawFixedRandom(0)); + AssertEqual(0, p.DrawState.PityRounds[15].Misses, "natural first-pull S"); + for (int i = 0; i < 9; i++) Roll(p, fate, new DrawFixedRandom(.999)); + AssertEqual(9, p.DrawState.PityRounds[15].Misses, "ten-pull counts only pulls after early S"); + Roll(p, fate, new DrawFixedRandom(.999)); + AssertEqual(0, p.DrawState.PityRounds[15].LowerMisses, "ten-pull lower-rarity guarantee resets its counter"); + AssertEqual(10, p.DrawState.PityRounds[15].Misses, "A guarantee does not reset S pity"); + Roll(p, theme, new DrawFixedRandom(.999)); + AssertEqual(10, p.DrawState.PityRounds[15].Misses, "normal and Fate do not share pity"); + AssertEqual(1, p.DrawState.PityRounds[11].Misses, "normal maintains own pity"); + var saved = BsonSerializer.Deserialize(p.DrawState.ToBson()); + AssertEqual(p.DrawState.PityRounds[15].Limit, saved.PityRounds[15].Limit, "random threshold survives persistence"); + + p = new(); + for (int i = 0; i < 39; i++) Roll(p, member, new DrawFixedRandom(.999)); + AssertEqual(40, p.DrawState.PityRounds[1].Limit, "Member first guarantee"); + Roll(p, member, new DrawFixedRandom(.999)); + AssertEqual(60, p.DrawState.PityRounds[1].Limit, "Member subsequent guarantee"); + + foreach (DrawInfo d in new[] { weapon, Template(12), Template(13) }) + { + p = new(); + Roll(p, d, new DrawFixedRandom(.999)); + var round = p.DrawState.PityRounds[d.GroupId]; + round.Misses = round.Limit - 1; + AssertEqual(false, Roll(p, d, new DrawFixedRandom(.999)).TemplateId == d.ResourceIds[1], "forced off-target"); + AssertEqual(true, round.GuaranteedTarget, "calibration recorded"); + p.DrawState = BsonSerializer.Deserialize(p.DrawState.ToBson()); + var changed = Version47CatalogTemplates().First(x => x.GroupId == d.GroupId && x.Id != d.Id); + round = p.DrawState.PityRounds[d.GroupId]; + round.Misses = round.Limit - 1; + AssertEqual(changed.ResourceIds[1], Roll(p, changed, new DrawFixedRandom(.999)).TemplateId, "calibration follows target switch and reload"); + AssertEqual(false, round.GuaranteedTarget, "calibration consumed"); + } + + foreach ((int drawId, bool permitsOffTarget) in new[] { (4001, true), (7002, true), (7069, false) }) + { + DrawInfo banner = Version47CatalogTemplates().Single(x => x.Id == drawId); + int[] rarePool = (int[])Call("RarePool", banner)!; + p = new(); + var round = (PlayerDrawPityRound)Call("GetPityRound", p, banner, new DrawFixedRandom(0))!; + round.Misses = round.Limit - 1; + AssertEqual(banner.ResourceIds[1], Roll(p, banner, new DrawFixedRandom(0)).TemplateId, + $"banner {drawId} deterministic target"); + p = new(); + round = (PlayerDrawPityRound)Call("GetPityRound", p, banner, new DrawFixedRandom(.999, true))!; + round.Misses = round.Limit - 1; + int upperResult = Roll(p, banner, new DrawFixedRandom(.999, true)).TemplateId; + AssertEqual(permitsOffTarget, upperResult != banner.ResourceIds[1], + $"banner {drawId} per-banner target rate"); + AssertEqual(true, rarePool.Contains(upperResult), $"banner {drawId} result remains in rare pool"); + } + + using (MongoCollectionOverride mongo = MongoCollectionOverride.InstallForDailySignInCompatibility(out var playerSaves, out _, out _)) + { + DrawGroupInfo active = ((List)Call("GetDrawGroupInfos", new Player())!).First(x => x.Id != 1); + long uid = 47_190; + using LoopbackSessionHarness harness = new(CreateDrawCompatibilityCharacter(uid), CreateDrawCompatibilityPlayer(uid), + CreateDrawCompatibilityInventory(uid, []), "draw-pity-initialization"); + const int firstPacket = 47_191; + InvokeRegisteredRequestHandler(nameof(DrawGetDrawInfoListRequest), harness.Session, firstPacket, + new DrawGetDrawInfoListRequest { GroupId = active.Id }); + DrawGetDrawInfoListResponse first = ReadResponsePayload(harness, firstPacket, + nameof(DrawGetDrawInfoListResponse), "initial durable draw pity"); + Player reloaded = BsonSerializer.Deserialize(playerSaves.LastSuccessfulReplacementBson + ?? throw new InvalidDataException("Draw info read did not persist newly sampled pity.")); + int firstLimit = first.DrawInfoList.First().MaxBottomTimes; + AssertEqual(firstLimit, reloaded.DrawState.PityRounds[active.Id].Limit, "catalog response persisted sampled pity"); + harness.Session.player = reloaded; + const int secondPacket = 47_192; + InvokeRegisteredRequestHandler(nameof(DrawGetDrawInfoListRequest), harness.Session, secondPacket, + new DrawGetDrawInfoListRequest { GroupId = active.Id }); + DrawGetDrawInfoListResponse second = ReadResponsePayload(harness, secondPacket, + nameof(DrawGetDrawInfoListResponse), "reloaded durable draw pity"); + AssertEqual(firstLimit, second.DrawInfoList.First().MaxBottomTimes, "reload preserves sampled pity"); + } + + p = new(); + Random random = new(7419); + int rare = 0; + const int attempts = 200000; + for (int i = 0; i < attempts; i++) + { + if (Roll(p, fate, random).TemplateId == fate.ResourceIds[1]) rare++; + } + double fateOverall = (double)Call("OverallRareProbability", fate)!; + double normalOverall = (double)Call("OverallRareProbability", theme)!; + AssertEqual(true, Math.Abs(fateOverall - normalOverall) < 1e-12, + $"Fate combined rate matches normal ({fateOverall:P8})"); + double simulatedOverall = (double)rare / attempts; + AssertEqual(true, Math.Abs(simulatedOverall - normalOverall) < .0015, + $"Fate continuous-pity simulation ({rare}/{attempts}, {simulatedOverall:P4})"); + // All currently advertised pools can produce both rare and non-rare outcomes. + var groups = (List)Call("GetDrawGroupInfos", new Player())!; + foreach (var group in groups.Where(x => x.Id != 1)) + { + var draws = (List)Call("GetDrawInfosByGroup", group.Id, new Player())!; + foreach (var d in draws) + { + Roll(new Player(), d, new DrawFixedRandom(0)); + for (int i = 0; i < 20; i++) Roll(new Player(), d, new DrawFixedRandom((i + .5) / 20)); + } + } + Console.WriteLine($"Draw rules passed; Fate combined-rate sample {rare}/{attempts} ({simulatedOverall:P4})."); + } +} diff --git a/AscNet.Test/Program.DrawTaskProgress.cs b/AscNet.Test/Program.DrawTaskProgress.cs index 15a1e5ef..3bba6a34 100644 --- a/AscNet.Test/Program.DrawTaskProgress.cs +++ b/AscNet.Test/Program.DrawTaskProgress.cs @@ -38,7 +38,9 @@ private static void ValidateDrawTaskProgressCompatibility() AssertEqual(0, success.Code, "bulk draw succeeds"); AssertEqual(0L, inventory.Items.Single(item => item.Id == draw.UseItemId).Count, "bulk draw consumes exact ticket cost"); AssertEqual(count, success.ClientDrawInfo!.TotalCount, "bulk draw commits all pulls"); - AssertEqual(count, character.Partners.Count, "bulk draw grants all CUBs"); + int acquiredPartners = success.RewardGoodsList.Count(x => x.RewardType == (int)RewardType.Partner); + AssertEqual(acquiredPartners, character.Partners.Count, "bulk draw persists actual CUB outcomes"); + AssertEqual(true, acquiredPartners > 0, "ten pulls guarantee at least an A CUB"); AssertEqual(true, character.Partners.All(partner => harness.Session.player.ArchivePartnerUnlockIds.Contains(partner.TemplateId)), "acquired CUBs unlock archive membership"); foreach (int id in otherCurrencyConditions) @@ -51,7 +53,7 @@ private static void ValidateDrawTaskProgressCompatibility() nameof(DrawDrawCardResponse), "unaffordable draw", typeof(DrawDrawCardResponse), maxPacketsToRead: 64); if (failure.Code == 0) throw new InvalidDataException("Unaffordable draw succeeded."); - AssertEqual(count, character.Partners.Count, "rejected draw grants no CUBs"); + AssertEqual(acquiredPartners, character.Partners.Count, "rejected draw grants no CUBs"); AssertEqual(count, harness.Session.player.DrawState.ProgressByDrawId[draw.Id].TotalCount, "rejected draw adds no pulls"); foreach (int id in otherCurrencyConditions) diff --git a/AscNet.Test/Program.Version47DrawCub.cs b/AscNet.Test/Program.Version47DrawCub.cs index 08b76041..419fd6f3 100644 --- a/AscNet.Test/Program.Version47DrawCub.cs +++ b/AscNet.Test/Program.Version47DrawCub.cs @@ -262,6 +262,7 @@ private static void AssertCubDrawAcquisitionCreatesDistinctInstances() for (int attempt = 0; attempt < 2; attempt++) { int before = character.Partners.Count; + harness.Session.player.DrawState.PityRounds[cubDraw.GroupId] = new() { Limit = cubDraw.MaxBottomTimes, Misses = cubDraw.MaxBottomTimes - 1 }; InvokeRegisteredRequestHandler(nameof(DrawDrawCardRequest), harness.Session, packetId, new DrawDrawCardRequest { DrawId = cubDraw.Id, Count = 1, UseDrawTicketId = 0 }); Packet pushPacket = harness.ReadPacket($"draw attempt {attempt} first packet"); diff --git a/AscNet.Test/Program.cs b/AscNet.Test/Program.cs index 682c35e1..9c07334b 100644 --- a/AscNet.Test/Program.cs +++ b/AscNet.Test/Program.cs @@ -81,6 +81,11 @@ static void Main(string[] args) try { UseResourceWorkingDirectory(); + if (args.Contains("--draw-rules-only")) + { + ValidateDrawRules(); + return; + } if (args.Contains("--guild-compat-only")) { ValidateGuildMembershipCompatibility(); @@ -948,6 +953,7 @@ static void Main(string[] args) ValidateEquipDecomposeCompatibility(); ValidateEquipChipRecycleCompatibility(); ValidateDrawCompatibility(); + ValidateDrawRules(); ValidateItemUseCompatibility(); ValidateAutoUseGiftCompatibility(); ValidateItemSellCompatibility(); diff --git a/Resources/Configs/draw-rules-source.md b/Resources/Configs/draw-rules-source.md new file mode 100644 index 00000000..2ea75770 --- /dev/null +++ b/Resources/Configs/draw-rules-source.md @@ -0,0 +1,32 @@ +# Draw rule provenance + +Per the maintainer's corrected instruction on 2026-09-10, client tables take +precedence over the initial comparison table. DrawProbShow.tsv is read directly: +normal characters 0.5%, Fate 1.5%, weapons 5% total (targeted 4% plus two 0.5% +off-targets), Uniframes 5%, CUBs 5.82%. +DrawServerRule.tsv transcribes guarantees and fallback target rates from DrawGroupRule.tsv. +Per-banner target rates come from DrawAimProbability.tsv, so banners in one group +may retain different rates. Arrival and targeted weapons calibrate after an off-target rare. +Fate limits are sampled inclusively from 80 to 100 once per round. The client +specifies the range, 1.5% base rate, and equality with the corresponding normal +pool's combined rate, but does not expose the server's threshold weights. The +emulator therefore uses the unique maximum-entropy full-support distribution +that satisfies those published constraints; weights are derived at runtime from +DrawGroupRule, DrawProbShow, and DrawServerRule rather than captured values. +Member's initial limit is 40, then 60. Target percentages apply conditional on +obtaining the highest rarity, not as additional independent rolls. + +DrawGroupRule.tsv supplies group identities. DrawPreview supplies eligible +rewards (both GoodsId and UpGoodsId). Uniframes use the client's 100% target rule. +CUB previews contain the selected S and lower-rarity CUBs, so S targets are 100%. +No calibration is invented for +CUBs/Uniframes where the supplied rules leave it version dependent. +Crucible groups 35/36 retain their separate client-defined inheritance groups; +they are not assumed to be Phylotree Nexus. No Phylotree identity or Checked S +selection/remaining-use source is currently provided by the server catalog. +Those mechanisms require explicit catalog/selection data before activation. + +Legacy lifetime counters remain intact for activity progress. A new persisted +round stores misses, sampled limit and calibration independently. Migration +preserves the legacy partial cycle; historical early S resets cannot be fully +reconstructed from the old bounded history. diff --git a/Resources/table/share/draw/DrawServerRule.tsv b/Resources/table/share/draw/DrawServerRule.tsv new file mode 100644 index 00000000..e043c99f --- /dev/null +++ b/Resources/table/share/draw/DrawServerRule.tsv @@ -0,0 +1,16 @@ +GroupId TargetPercent FeaturedTargetPercent Calibration PityMin PityMax FirstPity PityGroupId LowerPity +1 0 0 0 60 60 40 1 10 +2 0 0 0 30 30 0 2 10 +4 80 80 1 30 30 0 4 10 +11 100 100 0 60 60 0 11 10 +12 70 70 1 60 60 0 12 10 +13 70 70 1 80 100 0 13 10 +15 100 100 0 80 100 0 15 10 +16 100 100 0 10 10 0 16 0 +17 100 100 0 60 60 0 17 10 +18 100 100 0 80 100 0 18 10 +22 100 100 0 20 20 0 22 10 +23 100 100 0 60 60 0 23 10 +24 100 100 0 80 100 0 24 10 +35 100 100 0 60 60 0 35 10 +36 100 100 0 80 100 0 36 10