From fbd35e09c531e53ae351490f713d971c7000ea49 Mon Sep 17 00:00:00 2001 From: "Zesen (Jason) Zhang" <72175577+ToumanLin@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:16:06 -0500 Subject: [PATCH] Implement table-driven draw rules and pity state --- AscNet.Common/Database/Player.Draw.cs | 13 ++ AscNet.GameServer/Game/DrawManager.Rules.cs | 172 ++++++++++++++++++ AscNet.GameServer/Game/DrawManager.cs | 26 ++- AscNet.Test/Program.DrawRules.cs | 103 +++++++++++ AscNet.Test/Program.DrawTaskProgress.cs | 6 +- AscNet.Test/Program.Version47DrawCub.cs | 1 + AscNet.Test/Program.cs | 6 + Resources/Configs/draw-rules-source.md | 26 +++ Resources/table/share/draw/DrawServerRule.tsv | 16 ++ 9 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 AscNet.GameServer/Game/DrawManager.Rules.cs create mode 100644 AscNet.Test/Program.DrawRules.cs create mode 100644 Resources/Configs/draw-rules-source.md create mode 100644 Resources/table/share/draw/DrawServerRule.tsv diff --git a/AscNet.Common/Database/Player.Draw.cs b/AscNet.Common/Database/Player.Draw.cs index 1e92066b..f081447b 100644 --- a/AscNet.Common/Database/Player.Draw.cs +++ b/AscNet.Common/Database/Player.Draw.cs @@ -38,6 +38,10 @@ public class PlayerDrawHistoryGroupState public class PlayerDrawState { + [BsonElement("pity_rounds")] + [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)] + public Dictionary PityRounds { get; set; } = new(); + [BsonElement("progress_by_draw_id")] [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)] public Dictionary ProgressByDrawId { get; set; } = new(); @@ -59,6 +63,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..110c8ee3 --- /dev/null +++ b/AscNet.GameServer/Game/DrawManager.Rules.cs @@ -0,0 +1,172 @@ +using AscNet.Common.Database; +using AscNet.Common.MsgPack; +using AscNet.Common.Util; +using AscNet.GameServer.Handlers; +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 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(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; + } + + private static int NextLimit(DrawServerRuleTable rule, PlayerDrawPityRound round, Random random) => + !round.HasObtainedRare && rule.FirstPity > 0 ? rule.FirstPity : random.Next(rule.PityMin, rule.PityMax + 1); + + 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); + int targetPercent = featured ? rule.FeaturedTargetPercent : rule.TargetPercent; + bool hasTarget = targetPercent > 0 && pool.Contains(target); + if (targetPercent > 0 && !hasTarget) + throw new InvalidDataException($"Draw {draw.Id} target {target} is missing from its highest-rarity preview"); + bool hit = hasTarget && (round.GuaranteedTarget || random.NextDouble() < targetPercent / 100d); + 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(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 8bc05eaf..2f7deada 100644 --- a/AscNet.GameServer/Game/DrawManager.cs +++ b/AscNet.GameServer/Game/DrawManager.cs @@ -12,7 +12,7 @@ namespace AscNet.GameServer.Game; -internal static class DrawManager +internal static partial class DrawManager { internal const int CatalogUnavailableCode = 1; private const int MinDrawItemShowQuality = 3; @@ -5128,8 +5128,9 @@ 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 = GetBottomTimes(selected, GetPityCount(player, group.Id)); - value.MaxBottomTimes = selected.MaxBottomTimes; + DrawInfo status = BuildDrawInfo(selected, player); + value.BottomTimes = status.BottomTimes; + value.MaxBottomTimes = status.MaxBottomTimes; return value; }).ToList(); } @@ -5140,7 +5141,8 @@ 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 (GetBottomTimes(draw, GetPityCount(player, groupId)), 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) @@ -5208,15 +5210,8 @@ public static int SetUseDrawId(Player player, int drawId) public static List DrawDraw(Player player, int drawId, int pullOffset = 0) { if (!DrawsById.TryGetValue(drawId, out DrawInfo? draw) || !IsActive(draw)) return []; - 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(); @@ -5290,7 +5285,10 @@ private static DrawInfo BuildDrawInfo(DrawInfo template, Player player) PlayerDrawProgress progress = GetProgress(player, template.Id); value.TodayCount = progress.TodayCount; value.TotalCount = progress.TotalCount; - value.BottomTimes = GetBottomTimes(template, GetPityCount(player, template.GroupId)); + 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.Test/Program.DrawRules.cs b/AscNet.Test/Program.DrawRules.cs new file mode 100644 index 00000000..8918263c --- /dev/null +++ b/AscNet.Test/Program.DrawRules.cs @@ -0,0 +1,103 @@ +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(); + 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(); + 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"); + } + + p = new(); + Random random = new(7419); + int rare = 0; + HashSet thresholds = []; + for (int i = 0; i < 20000; i++) + { + var round = (PlayerDrawPityRound)Call("GetPityRound", p, fate, random)!; + thresholds.Add(round.Limit); + round.Misses = 0; // Isolate base rate from guarantees. + if (Roll(p, fate, random).TemplateId == fate.ResourceIds[1]) rare++; + } + AssertEqual(true, rare is > 220 and < 390, $"Fate base-rate simulation ({rare}/20000)"); + AssertEqual(21, thresholds.Count, "all inclusive Fate thresholds sampled"); + // All currently advertised pools can produce both rare and non-rare outcomes. + var groups = (List)Call("GetDrawGroupInfos", new Player())!; + foreach (var group in groups) + { + 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 base-rate sample {rare}/20000, 21 guarantee thresholds."); + } +} 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 c30103b9..bd0e2f65 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("--wheelchair-manual-compat-only")) { ValidateWheelchairManualFullCompatibility(); @@ -887,6 +892,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..a19adabf --- /dev/null +++ b/Resources/Configs/draw-rules-source.md @@ -0,0 +1,26 @@ +# 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 target rates from DrawGroupRule.tsv. +Fate limits are sampled uniformly and inclusively from 80 to 100 once +per round. Arrival and targeted weapons calibrate after an off-target rare. +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