diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bd4cfbe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run MikuSB (build output dir)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/MikuSB/bin/Debug/net10.0/MikuSB.exe", + "args": [], + "cwd": "${workspaceFolder}/MikuSB/bin/Debug/net10.0", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ff5aa6a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run MikuSB", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + ".\\MikuSB\\MikuSB.csproj" + ], + "isBackground": true, + "group": "build" + } + ] +} \ No newline at end of file diff --git a/Common/Configuration/ConfigContainer.cs b/Common/Configuration/ConfigContainer.cs index 325ddb0..5b4253b 100644 --- a/Common/Configuration/ConfigContainer.cs +++ b/Common/Configuration/ConfigContainer.cs @@ -61,6 +61,7 @@ public class ServerOption public string FallbackLanguage { get; set; } = "EN"; public string[] DefaultPermissions { get; set; } = ["Admin"]; public ServerProfile ServerProfile { get; set; } = new(); + public bool EnableAutoUpdate { get; set; } = true; public bool EnableGmMenu { get; set; } = false; public bool AutoCreateUser { get; set; } = true; public bool SavePersonalDebugFile { get; set; } = false; diff --git a/Common/Data/Excel/BattlePassTimeExcel.cs b/Common/Data/Excel/BattlePassTimeExcel.cs new file mode 100644 index 0000000..1089ef5 --- /dev/null +++ b/Common/Data/Excel/BattlePassTimeExcel.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("battlepass/timelist.json")] +public class BattlePassTimeExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("BuyStartTime")] public string BuyStartTime { get; set; } = ""; + [JsonProperty("BuyEndTime")] public string BuyEndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + [JsonProperty("ExpStep")] public uint ExpStep { get; set; } + [JsonProperty("MaxExPerWeek")] public uint MaxExPerWeek { get; set; } + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.BattlePassTimeData[Id] = this; + } +} diff --git a/Common/Data/Excel/BossPvpBossChallengeExcel.cs b/Common/Data/Excel/BossPvpBossChallengeExcel.cs new file mode 100644 index 0000000..afdc459 --- /dev/null +++ b/Common/Data/Excel/BossPvpBossChallengeExcel.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/boss_challenge.json")] +public class BossPvpBossChallengeExcel : ExcelResource +{ + public uint ID { get; set; } + public string StartTime { get; set; } = ""; + public string EndTime { get; set; } = ""; + public List tbTaskID { get; set; } = []; + + [JsonExtensionData] public IDictionary ExtraData { get; set; } = new Dictionary(); + + [JsonIgnore] public List BossIds { get; private set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + BossIds = ExtraData + .Where(x => x.Key.StartsWith("Boss", StringComparison.Ordinal) && int.TryParse(x.Key[4..], out _)) + .OrderBy(x => int.Parse(x.Key[4..], CultureInfo.InvariantCulture)) + .Select(x => x.Value.Type == JTokenType.Integer ? x.Value.Value() : 0u) + .Where(x => x > 0) + .ToList(); + + GameData.BossPvpBossChallengeData[ID] = this; + } +} diff --git a/Common/Data/Excel/BossPvpBossExcel.cs b/Common/Data/Excel/BossPvpBossExcel.cs new file mode 100644 index 0000000..1f07907 --- /dev/null +++ b/Common/Data/Excel/BossPvpBossExcel.cs @@ -0,0 +1,17 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/boss.json")] +public class BossPvpBossExcel : ExcelResource +{ + public uint ID { get; set; } + public uint LevelID { get; set; } + public uint BossID { get; set; } + public List> BossLevel { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.BossPvpBossData[ID] = this; + } +} diff --git a/Common/Data/Excel/BossPvpNumExcel.cs b/Common/Data/Excel/BossPvpNumExcel.cs new file mode 100644 index 0000000..85d49d9 --- /dev/null +++ b/Common/Data/Excel/BossPvpNumExcel.cs @@ -0,0 +1,15 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/num.json")] +public class BossPvpNumExcel : ExcelResource +{ + public uint Week { get; set; } + public uint Num { get; set; } + + public override uint GetId() => Week; + + public override void Loaded() + { + GameData.BossPvpNumData[Week] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerAwardExcel.cs b/Common/Data/Excel/ClimbTowerAwardExcel.cs new file mode 100644 index 0000000..c33013a --- /dev/null +++ b/Common/Data/Excel/ClimbTowerAwardExcel.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_award.json")] +public class ClimbTowerAwardExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("Diff")] public JToken? DiffRaw { get; set; } + [JsonProperty("FirstAward")] public List> FirstAward { get; set; } = []; + [JsonProperty("StarCount1")] public int StarCount1 { get; set; } + [JsonProperty("StarAward1")] public List> StarAward1 { get; set; } = []; + [JsonProperty("StarCount2")] public int StarCount2 { get; set; } + [JsonProperty("StarAward2")] public List> StarAward2 { get; set; } = []; + [JsonProperty("StarCount3")] public int StarCount3 { get; set; } + [JsonProperty("StarAward3")] public List> StarAward3 { get; set; } = []; + + [JsonIgnore] + public int Diff => DiffRaw?.Type switch + { + JTokenType.Integer => Math.Max(1, DiffRaw.Value()), + JTokenType.String when int.TryParse(DiffRaw.Value(), out var value) => Math.Max(1, value), + _ => 1 + }; + + public override uint GetId() => (ID * 10u) + (uint)Diff; + + public override void Loaded() + { + if (!GameData.ClimbTowerAwardData.TryGetValue(ID, out var diffMap)) + { + diffMap = []; + GameData.ClimbTowerAwardData[ID] = diffMap; + } + + diffMap[Diff] = this; + } + + public int GetStarCount(int group) => group switch + { + 1 => StarCount1, + 2 => StarCount2, + 3 => StarCount3, + _ => 0 + }; + + public IReadOnlyList> GetRewards(int group) => group switch + { + 0 => FirstAward, + 1 => StarAward1, + 2 => StarAward2, + 3 => StarAward3, + _ => [] + }; +} diff --git a/Common/Data/Excel/ClimbTowerDiffExcel.cs b/Common/Data/Excel/ClimbTowerDiffExcel.cs new file mode 100644 index 0000000..6f89b4d --- /dev/null +++ b/Common/Data/Excel/ClimbTowerDiffExcel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_diff.json")] +public class ClimbTowerDiffExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("Level1")] public int Level1 { get; set; } + [JsonProperty("Level2")] public int Level2 { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerDiffData[ID] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs b/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs new file mode 100644 index 0000000..9e4fea1 --- /dev/null +++ b/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_levelorder.json")] +public class ClimbTowerLevelOrderExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("LevelID")] public uint LevelID { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerLevelOrderData[ID] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerTimeExcel.cs b/Common/Data/Excel/ClimbTowerTimeExcel.cs new file mode 100644 index 0000000..da1aed0 --- /dev/null +++ b/Common/Data/Excel/ClimbTowerTimeExcel.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_time.json")] +public class ClimbTowerTimeExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("Level1")] public List> Level1 { get; set; } = []; + [JsonProperty("Level2")] public JToken? Level2Raw { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerTimeData[ID] = this; + } + + public IReadOnlyList> GetLevelGroups(int type) + { + if (type == 1) + return Level1; + + if (Level2Raw == null) + return []; + + if (Level2Raw.Type == JTokenType.Array) + { + return Level2Raw + .Children() + .OfType() + .Select(x => (IReadOnlyList)x.Values().ToList()) + .ToList(); + } + + if (Level2Raw.Type == JTokenType.Object) + { + return Level2Raw + .Children() + .Select(x => new + { + Key = uint.TryParse(x.Name, CultureInfo.InvariantCulture, out var key) ? key : 0u, + Value = x.Value.Type == JTokenType.Integer ? x.Value.Value() : 0u + }) + .Where(x => x.Key > 0 && x.Value > 0) + .OrderBy(x => x.Key) + .Select(x => (IReadOnlyList)new List { x.Key, x.Value }) + .ToList(); + } + + return []; + } +} diff --git a/Common/Data/Excel/DlcActivityExcel.cs b/Common/Data/Excel/DlcActivityExcel.cs new file mode 100644 index 0000000..8250cf4 --- /dev/null +++ b/Common/Data/Excel/DlcActivityExcel.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/dlc_activities.json")] +public class DlcActivityExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("EnterStartTime")] public string EnterStartTime { get; set; } = ""; + [JsonProperty("CloseEndTime")] public string CloseEndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.DlcActivityData[Id] = this; + } +} diff --git a/Common/Data/Excel/DreamCardActivityExcel.cs b/Common/Data/Excel/DreamCardActivityExcel.cs new file mode 100644 index 0000000..ef739be --- /dev/null +++ b/Common/Data/Excel/DreamCardActivityExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/DreamCard/activity.json")] +public class DreamCardActivityExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + [JsonProperty("LevelListID")] public List LevelListID { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.DreamCardActivityData[ID] = this; + } +} diff --git a/Common/Data/Excel/FishingFoodExcel.cs b/Common/Data/Excel/FishingFoodExcel.cs new file mode 100644 index 0000000..0f3d7c5 --- /dev/null +++ b/Common/Data/Excel/FishingFoodExcel.cs @@ -0,0 +1,89 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/fishing/food.json")] +public class FishingFoodExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("FoodType")] public JToken? FoodTypeRaw { get; set; } + [JsonProperty("NeedItem")] public JToken? NeedItemRaw { get; set; } + [JsonProperty("CreateItems")] public JToken? CreateItemsRaw { get; set; } + [JsonProperty("EffectTime")] public JToken? EffectTimeRaw { get; set; } + [JsonProperty("FishingLevel")] public JToken? FishingLevelRaw { get; set; } + [JsonProperty("SeasonId")] public JToken? SeasonIdRaw { get; set; } + [JsonProperty("BaitNum")] public JToken? BaitNumRaw { get; set; } + [JsonProperty("FoodArea")] public JToken? FoodAreaRaw { get; set; } + + [JsonIgnore] public uint FoodType => ReadUInt(FoodTypeRaw); + [JsonIgnore] public uint EffectTime => ReadUInt(EffectTimeRaw); + [JsonIgnore] public uint FishingLevel => ReadUInt(FishingLevelRaw); + [JsonIgnore] public uint SeasonId => ReadUInt(SeasonIdRaw); + [JsonIgnore] public List> NeedItem => ReadNestedUIntList(NeedItemRaw); + [JsonIgnore] public List CreateItems => ReadUIntList(CreateItemsRaw); + [JsonIgnore] public List BaitNum => ReadUIntList(BaitNumRaw); + [JsonIgnore] public List FoodArea => ReadUIntList(FoodAreaRaw); + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.FishingFoodData[Id] = this; + } + + private static int ReadInt(JToken? token) + { + if (token == null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (int)token.Value(), + JTokenType.String when int.TryParse(token.Value(), out var value) => value, + _ => 0 + }; + } + + private static uint ReadUInt(JToken? token) + { + var value = ReadInt(token); + return value > 0 ? (uint)value : 0; + } + + private static List ReadUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + var result = new List(array.Count); + foreach (var item in array) + { + var value = ReadUInt(item); + if (value > 0) + result.Add(value); + } + + return result; + } + + private static List> ReadNestedUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + var result = new List>(array.Count); + foreach (var row in array.OfType()) + { + var values = new List(row.Count); + foreach (var item in row) + { + values.Add(ReadUInt(item)); + } + result.Add(values); + } + + return result; + } +} diff --git a/Common/Data/Excel/GachaExcel.cs b/Common/Data/Excel/GachaExcel.cs new file mode 100644 index 0000000..03f2715 --- /dev/null +++ b/Common/Data/Excel/GachaExcel.cs @@ -0,0 +1,46 @@ +using MikuSB.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("gacha/gacha.json")] +public class GachaExcel : ExcelResource +{ + public uint ID { get; set; } + public List? Pool { get; set; } + public uint Probability { get; set; } + public uint ProbabilityTen { get; set; } + public JToken? ProtectNum { get; set; } + public JToken? UpNum { get; set; } + public uint? ProtectTag { get; set; } + public uint? ProtectType { get; set; } + public JToken? ProtectCount { get; set; } + public uint? UpSelect { get; set; } + + public override uint GetId() => ID; + public override void Loaded() => GameData.GachaData[ID] = this; + + public override void AfterAllDone() + { + foreach (var poolName in Pool ?? []) + { + if (GameData.GachaPoolData.ContainsKey(poolName)) continue; + var path = ConfigManager.Config.Path.ResourcePath + "/gacha/pool/" + poolName + ".json"; + if (!File.Exists(path)) continue; + var json = File.ReadAllText(path); + var items = JsonConvert.DeserializeObject>(json) ?? []; + GameData.GachaPoolData[poolName] = items; + } + } +} + +public class GachaPoolItem +{ + public int ID { get; set; } + public int Rarity { get; set; } + public List GDPL { get; set; } = []; + public int Weight { get; set; } + public int? UPTag { get; set; } + public int? UPSelectTag { get; set; } +} diff --git a/Common/Data/Excel/GachaProbabilityExcel.cs b/Common/Data/Excel/GachaProbabilityExcel.cs new file mode 100644 index 0000000..d797989 --- /dev/null +++ b/Common/Data/Excel/GachaProbabilityExcel.cs @@ -0,0 +1,18 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("gacha/probability.json")] +public class GachaProbabilityExcel : ExcelResource +{ + public uint ID { get; set; } + public int Rarity1 { get; set; } + public int Rarity2 { get; set; } + public int Rarity3 { get; set; } + public int Rarity4 { get; set; } + public int Rarity5 { get; set; } + public int Rarity6 { get; set; } + + public int[] Weights => [Rarity1, Rarity2, Rarity3, Rarity4, Rarity5, Rarity6]; + + public override uint GetId() => ID; + public override void Loaded() => GameData.GachaProbabilityData[ID] = this; +} diff --git a/Common/Data/Excel/IbGoodsExcel.cs b/Common/Data/Excel/IbGoodsExcel.cs new file mode 100644 index 0000000..c7bbfd4 --- /dev/null +++ b/Common/Data/Excel/IbGoodsExcel.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("purchase/ibgoods.json")] +public class IbGoodsExcel : ExcelResource +{ + [JsonProperty("GoodsId")] private JToken? GoodsIdRaw { get; set; } + [JsonProperty("Type")] private JToken? TypeRaw { get; set; } + [JsonProperty("PreId")] private JToken? PreIdRaw { get; set; } + [JsonProperty("LimitTimes")] private JToken? LimitTimesRaw { get; set; } + [JsonProperty("Item")] private JToken? ItemRaw { get; set; } + [JsonProperty("Cost")] private JToken? CostRaw { get; set; } + [JsonProperty("Cost2")] private JToken? Cost2Raw { get; set; } + [JsonProperty("PcId")] public string PcId { get; set; } = ""; + [JsonProperty("IosId")] public string IosId { get; set; } = ""; + [JsonProperty("AndroidId")] public string AndroidId { get; set; } = ""; + + public override uint GetId() => GoodsId; + + public override void Loaded() + { + GameData.IbGoodsData[GoodsId] = this; + } + + [JsonIgnore] + public uint GoodsId => ReadUInt(GoodsIdRaw); + + [JsonIgnore] + public int Type => (int)ReadUInt(TypeRaw); + + [JsonIgnore] + public uint PreId => ReadUInt(PreIdRaw); + + [JsonIgnore] + public uint LimitTimes => ReadUInt(LimitTimesRaw); + + [JsonIgnore] + public List Item => ReadUIntList(ItemRaw); + + [JsonIgnore] + public List Cost => ReadUIntList(CostRaw); + + [JsonIgnore] + public List Cost2 => ReadUIntList(Cost2Raw); + + public string GetProductId() => + !string.IsNullOrWhiteSpace(PcId) ? PcId : + !string.IsNullOrWhiteSpace(AndroidId) ? AndroidId : + IosId; + + private static uint ReadUInt(JToken? token) + { + if (token == null || token.Type is JTokenType.Null or JTokenType.Undefined) + return 0; + + if (token.Type == JTokenType.Integer) + return token.Value(); + + if (token.Type == JTokenType.String && uint.TryParse(token.Value(), out var value)) + return value; + + return 0; + } + + private static List ReadUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + return array + .Select(entry => entry.Type == JTokenType.Integer ? entry.Value() : 0u) + .ToList(); + } +} diff --git a/Common/Data/Excel/MonsterCardExcel.cs b/Common/Data/Excel/MonsterCardExcel.cs new file mode 100644 index 0000000..6861cf5 --- /dev/null +++ b/Common/Data/Excel/MonsterCardExcel.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/templates/monster_card.json")] +public class MonsterCardExcel : ExcelResource +{ + [JsonProperty("Genre")] public uint Genre { get; set; } + [JsonProperty("Detail")] public uint Detail { get; set; } + [JsonProperty("Particular")] public uint Particular { get; set; } + [JsonProperty("Level")] public uint Level { get; set; } + [JsonProperty("Color")] public uint Color { get; set; } + [JsonProperty("RikiId")] public uint RikiId { get; set; } + [JsonProperty("CostValue")] public uint CostValue { get; set; } + [JsonProperty("Exp")] public uint Exp { get; set; } + + [JsonIgnore] + public ulong TemplateId => GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); + + public override uint GetId() => Particular; + + public override void Loaded() + { + GameData.MonsterCardData[TemplateId] = this; + } +} diff --git a/Common/Data/Excel/OtherItemExcel.cs b/Common/Data/Excel/OtherItemExcel.cs new file mode 100644 index 0000000..2db7076 --- /dev/null +++ b/Common/Data/Excel/OtherItemExcel.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/templates/others.json")] +public class OtherItemExcel : ExcelResource +{ + public uint Genre { get; set; } + public uint Detail { get; set; } + public uint Particular { get; set; } + public uint Level { get; set; } + public string LuaType { get; set; } = ""; + [JsonProperty("UseMode")] public JToken? UseModeRaw { get; set; } + [JsonProperty("Param1")] public JToken? Param1Raw { get; set; } + [JsonProperty("GMnum")] public JToken? GMnumRaw { get; set; } + + [JsonIgnore] + public uint UseMode => ReadUInt(UseModeRaw); + + [JsonIgnore] + public uint Param1 => ReadUInt(Param1Raw); + + [JsonIgnore] + public uint GMnum => ReadUInt(GMnumRaw); + + public override uint GetId() => (uint)GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); + + public override void Loaded() + { + GameData.OtherItemData[GetId()] = this; + } + + private static uint ReadUInt(JToken? token) + { + if (token == null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (uint)Math.Max(0, token.Value()), + JTokenType.String when uint.TryParse(token.Value(), out var value) => value, + _ => 0 + }; + } +} diff --git a/Common/Data/Excel/RoleLevelExcel.cs b/Common/Data/Excel/RoleLevelExcel.cs new file mode 100644 index 0000000..fddf417 --- /dev/null +++ b/Common/Data/Excel/RoleLevelExcel.cs @@ -0,0 +1,14 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/role/level.json")] +public class RoleLevelExcel : ExcelResource +{ + public uint ID { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.RoleLevelData[ID] = this; + } +} diff --git a/Common/Data/Excel/SpecialBreakExcel.cs b/Common/Data/Excel/SpecialBreakExcel.cs new file mode 100644 index 0000000..a1919f3 --- /dev/null +++ b/Common/Data/Excel/SpecialBreakExcel.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/cardbreak/breaknew.json")] +public class SpecialBreakExcel : ExcelResource +{ + [JsonProperty("ID")] public int Id { get; set; } + + [JsonProperty("1Items1")] public List> Items1 { get; set; } = []; + [JsonProperty("2Items1")] public List> Items2 { get; set; } = []; + [JsonProperty("3Items1")] public List> Items3 { get; set; } = []; + [JsonProperty("4Items1")] public List> Items4 { get; set; } = []; + + public List> GetItems(uint breakLevel) => breakLevel switch + { + 1 => Items1, + 2 => Items2, + 3 => Items3, + 4 => Items4, + _ => [] + }; + + public bool HasBreakLevel(uint breakLevel) => breakLevel switch + { + 1 => Items1.Count > 0, + 2 => Items2.Count > 0, + 3 => Items3.Count > 0, + 4 => Items4.Count > 0, + _ => false + }; + + public override uint GetId() => (uint)Id; + + public override void Loaded() + { + GameData.SpecialBreakData[Id] = this; + } +} diff --git a/Common/Data/Excel/SupportCardExcel.cs b/Common/Data/Excel/SupportCardExcel.cs index 91daf3a..23239f5 100644 --- a/Common/Data/Excel/SupportCardExcel.cs +++ b/Common/Data/Excel/SupportCardExcel.cs @@ -39,7 +39,6 @@ public class SupportCardExcel : ExcelResource [JsonIgnore] public IReadOnlyList FixedAffixCost => ParseFlatCost(FixedAffixCostRaw); - public ulong TemplateId => GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); public override uint GetId() => Icon; diff --git a/Common/Data/Excel/TowerEventLevelExcel.cs b/Common/Data/Excel/TowerEventLevelExcel.cs new file mode 100644 index 0000000..4e9218f --- /dev/null +++ b/Common/Data/Excel/TowerEventLevelExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/tower_event/level.json")] +public class TowerEventLevelExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("MapID")] public uint MapID { get; set; } + [JsonProperty("FightID")] public uint FightID { get; set; } + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + [JsonProperty("ConsumeVigor")] public List ConsumeVigor { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.TowerEventLevelData[ID] = this; + } +} diff --git a/Common/Data/Excel/TowerLevelExcel.cs b/Common/Data/Excel/TowerLevelExcel.cs new file mode 100644 index 0000000..0163f8f --- /dev/null +++ b/Common/Data/Excel/TowerLevelExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/level.json")] +public class TowerLevelExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("MapID")] public uint MapID { get; set; } + [JsonProperty("FightID")] public uint FightID { get; set; } + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + [JsonProperty("ConsumeVigor")] public List ConsumeVigor { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.TowerLevelData[ID] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs b/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs new file mode 100644 index 0000000..c1c3869 --- /dev/null +++ b/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/captureregion.json")] +public class VirCaptureCaptureRegionExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("MapId")] public uint MapId { get; set; } + [JsonProperty("LevelRegionName")] public string LevelRegionName { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureCaptureRegionData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureLevelListExcel.cs b/Common/Data/Excel/VirCaptureLevelListExcel.cs new file mode 100644 index 0000000..4c4e945 --- /dev/null +++ b/Common/Data/Excel/VirCaptureLevelListExcel.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/levellist.json")] +public class VirCaptureLevelListExcel : ExcelResource +{ + [JsonProperty("Level")] public uint Level { get; set; } + [JsonProperty("Exp")] public uint Exp { get; set; } + [JsonProperty("Num")] public uint Num { get; set; } + [JsonProperty("MaxCost")] public uint MaxCost { get; set; } + [JsonProperty("Rewards")] public List> Rewards { get; set; } = []; + [JsonProperty("ExpUp")] public double ExpUp { get; set; } + + public override uint GetId() => Level; + + public override void Loaded() + { + GameData.VirCaptureLevelListData[Level] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureSeasonExcel.cs b/Common/Data/Excel/VirCaptureSeasonExcel.cs new file mode 100644 index 0000000..22a3ab0 --- /dev/null +++ b/Common/Data/Excel/VirCaptureSeasonExcel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/season.json")] +public class VirCaptureSeasonExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureSeasonData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTimeExcel.cs b/Common/Data/Excel/VirCaptureTimeExcel.cs new file mode 100644 index 0000000..9cf04c3 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTimeExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/timelist.json")] +public class VirCaptureTimeExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("CaptureRegionId")] public List CaptureRegionId { get; set; } = []; + [JsonProperty("MaxExp")] public uint MaxExp { get; set; } + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureTimeData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTowerExcel.cs b/Common/Data/Excel/VirCaptureTowerExcel.cs new file mode 100644 index 0000000..c7ee1d8 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTowerExcel.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/tower.json")] +public class VirCaptureTowerExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("Condition")] public JToken? ConditionRaw { get; set; } + [JsonProperty("MapID")] public uint MapId { get; set; } + [JsonProperty("TrialCard")] public List TrialCard { get; set; } = []; + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + + [JsonIgnore] + public Dictionary Condition { get; } = []; + + public override uint GetId() => Id; + + public override void Loaded() + { + Condition.Clear(); + if (ConditionRaw is JObject obj) + { + foreach (var property in obj.Properties()) + { + if (!int.TryParse(property.Name, out var key)) + continue; + + uint value = 0; + if (property.Value.Type == JTokenType.Integer) + value = property.Value.Value(); + else if (property.Value.Type == JTokenType.String && + uint.TryParse(property.Value.Value(), out var parsed)) + value = parsed; + + if (value > 0) + Condition[key] = value; + } + } + + GameData.VirCaptureTowerData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTrialTimeExcel.cs b/Common/Data/Excel/VirCaptureTrialTimeExcel.cs new file mode 100644 index 0000000..bdeb804 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTrialTimeExcel.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/trial_timelist.json")] +public class VirCaptureTrialTimeExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("AwardTime")] public string AwardTime { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureTrialTimeData[Id] = this; + } +} diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index 5a665c8..6c64591 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -13,6 +13,7 @@ public static class GameData public static Dictionary BreakLevelLimitData { get; private set; } = []; public static Dictionary RecycleData { get; private set; } = []; public static Dictionary ChapterLevelData { get; private set; } = []; + public static Dictionary RoleLevelData { get; private set; } = []; public static Dictionary ArItemData { get; private set; } = []; public static Dictionary ManifestationData { get; private set; } = []; public static Dictionary Rogue3DDifficultData { get; private set; } = []; @@ -20,6 +21,7 @@ public static class GameData public static Dictionary Rogue3DTalentData { get; private set; } = []; public static Dictionary Rogue3DDailyBuffData { get; private set; } = []; public static Dictionary BreakData { get; private set; } = []; + public static Dictionary SpecialBreakData { get; private set; } = []; public static Dictionary SpineData { get; private set; } = []; public static Dictionary NodeConditionData { get; private set; } = []; public static List SupportCardData { get; private set; } = []; @@ -28,6 +30,16 @@ public static class GameData public static Dictionary SupportFixedData { get; private set; } = []; public static Dictionary WeaponSkinData { get; private set; } = []; public static Dictionary DailyLevelData { get; private set; } = []; + public static Dictionary BossPvpBossChallengeData { get; private set; } = []; + public static Dictionary BossPvpBossData { get; private set; } = []; + public static Dictionary BossPvpNumData { get; private set; } = []; + public static Dictionary ClimbTowerTimeData { get; private set; } = []; + public static Dictionary ClimbTowerDiffData { get; private set; } = []; + public static Dictionary> ClimbTowerAwardData { get; private set; } = []; + public static Dictionary ClimbTowerLevelOrderData { get; private set; } = []; + public static Dictionary TowerLevelData { get; private set; } = []; + public static Dictionary TowerEventLevelData { get; private set; } = []; + public static Dictionary OtherItemData { get; private set; } = []; public static Dictionary ProfileData { get; private set; } = []; public static Dictionary CardSkinPartsData { get; private set; } = []; public static Dictionary CallItemData { get; private set; } = []; @@ -35,6 +47,21 @@ public static class GameData public static Dictionary GuideData { get; private set; } = []; public static Dictionary DormGiftData { get; private set; } = []; public static Dictionary HouseFurniturePosData { get; private set; } = []; + public static Dictionary GachaData { get; private set; } = []; + public static Dictionary GachaProbabilityData { get; private set; } = []; + public static Dictionary> GachaPoolData { get; private set; } = []; + public static Dictionary VirCaptureTimeData { get; private set; } = []; + public static Dictionary VirCaptureSeasonData { get; private set; } = []; + public static Dictionary VirCaptureTrialTimeData { get; private set; } = []; + public static Dictionary VirCaptureCaptureRegionData { get; private set; } = []; + public static Dictionary VirCaptureLevelListData { get; private set; } = []; + public static Dictionary MonsterCardData { get; private set; } = []; + public static Dictionary FishingFoodData { get; private set; } = []; + public static Dictionary VirCaptureTowerData { get; private set; } = []; + public static Dictionary DreamCardActivityData { get; private set; } = []; + public static Dictionary DlcActivityData { get; private set; } = []; + public static Dictionary BattlePassTimeData { get; private set; } = []; + public static Dictionary IbGoodsData { get; private set; } = []; } public static class GameResourceTemplateId @@ -44,4 +71,4 @@ public static ulong FromGdpl(uint genre, uint detail, uint particular, uint leve public static ulong FromGdpl(IReadOnlyList gdpl) => gdpl.Count >= 4 ? FromGdpl(gdpl[0], gdpl[1], gdpl[2], gdpl[3]) : 0; -} \ No newline at end of file +} diff --git a/Common/Database/Account/AccountData.cs b/Common/Database/Account/AccountData.cs index d75f5b8..84dca94 100644 --- a/Common/Database/Account/AccountData.cs +++ b/Common/Database/Account/AccountData.cs @@ -40,6 +40,11 @@ public class AccountData : BaseDatabaseDataHelper return result; } + public static AccountData? GetFirstAccount() + => DatabaseHelper.GetAllInstance()? + .OrderBy(account => account.Uid) + .FirstOrDefault(); + public static AccountData? GetAccountByDispatchToken(string dispatchToken) { AccountData? result = null; diff --git a/Config/Config.json b/Config/Config.json index 0ba1d80..5727b82 100644 --- a/Config/Config.json +++ b/Config/Config.json @@ -31,6 +31,7 @@ "Name": "Miku-chan", "Uid": 80 }, + "EnableAutoUpdate": true, "AutoCreateUser": true, "SavePersonalDebugFile": false, "AutoSendResponseWhenNoHandler": true, diff --git a/GameServer/Game/BossPvp/BossPvpService.cs b/GameServer/Game/BossPvp/BossPvpService.cs new file mode 100644 index 0000000..2914a9f --- /dev/null +++ b/GameServer/Game/BossPvp/BossPvpService.cs @@ -0,0 +1,498 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Inventory; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Game.BossPvp; + +internal static class BossPvpService +{ + private const uint GroupId = 51; + private const uint ActivitySubId = 0; + private const uint ChallengeNumSid = 1; + private const uint DiffStartId = 10; + private const uint LevelStartSid = 100; + private const uint LevelStride = 10; + private const uint BossLineup1 = 15; + private const uint BossLineup2 = 16; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static async ValueTask<(object Response, NtfSyncPlayer Sync)> HandleGetOpenIdAsync(PlayerInstance player) + { + await EnsureBossLineupsAsync(player); + + var sync = new NtfSyncPlayer(); + var season = GetOpenSeason(); + var seasonId = season?.ID ?? 1u; + + SetStr(player, ActivitySubId, seasonId.ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, ChallengeNumSid, GetDailyChallengeNum().ToString(CultureInfo.InvariantCulture), sync); + + if (season != null) + { + for (var index = 0; index < season.BossIds.Count; index++) + { + var bossLevelId = season.BossIds[index]; + EnsureStr(player, DiffStartId + (uint)(index + 1), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 1), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 2), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 3), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 4), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 5), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 6), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 7), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 8), "0", sync); + } + } + + var response = new + { + nID = seasonId, + tbTimeCfg = new[] + { + new + { + nStartTime = -1, + nEndTime = -1 + } + } + }; + + return (response, sync); + } + + public static object HandleEnterLevel(string? param) + { + var req = Deserialize(param); + return new + { + nSeed = Random.Shared.Next(1, int.MaxValue), + nID = req?.NId ?? 0 + }; + } + + public static (object Response, NtfSyncPlayer Sync) HandleRecord(PlayerInstance player, string? param) + { + var req = Deserialize(param); + if (req == null) + { + return (new { bRecord = false }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + if (req.BRecord) + { + var historyScore = ReadInt(player, GetBossSid(req.NId, 4)); + var currentScore = ComputeIntegral(req.NId, req.NDiff, req.ResidueTime); + if (currentScore >= historyScore) + { + WriteBestRun(player, req.NId, req.NTeamId, req.NTime, currentScore, sync); + } + } + + return (new { bRecord = req.BRecord }, sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? param) + { + var req = param?.Deserialize(JsonOptions); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new JsonObject(), sync); + } + + var totalSid = GetBossSid(req.NId, 7); + var successSid = GetBossSid(req.NId, 6); + var diffSid = GetBossSid(req.NId, 8); + + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, successSid, (ReadInt(player, successSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + var clearedDiff = Math.Max(ReadInt(player, diffSid), req.NDiff); + SetStr(player, diffSid, clearedDiff.ToString(CultureInfo.InvariantCulture), sync); + + var positionSid = TryGetPositionDiffSid(req.NId); + if (positionSid != null) + { + var newPositionDiff = Math.Max(ReadInt(player, positionSid.Value), req.NDiff); + SetStr(player, positionSid.Value, newPositionDiff.ToString(CultureInfo.InvariantCulture), sync); + } + + var score = ComputeIntegral(req.NId, req.NDiff, req.ResidueTime); + if (score > ReadInt(player, GetBossSid(req.NId, 4))) + { + WriteBestRun(player, req.NId, req.NTeamId, req.NTime, score, sync); + } + + return (new JsonObject(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleFail(PlayerInstance player, JsonNode? param) + { + var req = param?.Deserialize(JsonOptions); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new JsonObject(), sync); + } + + var totalSid = GetBossSid(req.NId, 7); + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + return (new JsonObject(), sync); + } + + public static (object Response, NtfSyncPlayer Sync) HandleMopup(PlayerInstance player, string? param) + { + var req = Deserialize(param); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new { }, sync); + } + + var totalSid = GetBossSid(req.NId, 7); + var successSid = GetBossSid(req.NId, 6); + var diffSid = GetBossSid(req.NId, 8); + + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, successSid, (ReadInt(player, successSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + var clearedDiff = Math.Max(ReadInt(player, diffSid), req.NDiff); + SetStr(player, diffSid, clearedDiff.ToString(CultureInfo.InvariantCulture), sync); + + var positionSid = TryGetPositionDiffSid(req.NId); + if (positionSid != null) + { + var newPositionDiff = Math.Max(ReadInt(player, positionSid.Value), req.NDiff + 1); + SetStr(player, positionSid.Value, newPositionDiff.ToString(CultureInfo.InvariantCulture), sync); + } + + var score = ComputeIntegral(req.NId, req.NDiff, 0); + if (score > ReadInt(player, GetBossSid(req.NId, 4))) + { + WriteBestRun(player, req.NId, 0, 0, score, sync); + } + + return (new { }, sync); + } + + public static object HandleGetReward(string? param) + { + _ = Deserialize(param); + return new { tbAward = Array.Empty() }; + } + + private static async ValueTask EnsureBossLineupsAsync(PlayerInstance player) + { + var lineups = player.LineupManager.LineupData.LineupInfo; + var baseLineup = lineups.GetValueOrDefault(1) ?? lineups.Values.FirstOrDefault(); + if (baseLineup == null) + { + return; + } + + if (!lineups.ContainsKey((int)BossLineup1)) + { + await player.LineupManager.UpdateLineup((int)BossLineup1, baseLineup.Member1, baseLineup.Member2, baseLineup.Member3, true); + } + + if (!lineups.ContainsKey((int)BossLineup2)) + { + await player.LineupManager.UpdateLineup((int)BossLineup2, baseLineup.Member1, baseLineup.Member2, baseLineup.Member3, true); + } + } + + private static void WriteBestRun(PlayerInstance player, uint bossLevelId, uint lineupId, double finishTime, int score, NtfSyncPlayer sync) + { + var snapshots = CaptureLineupSnapshots(player, lineupId); + SetStr(player, GetBossSid(bossLevelId, 1), System.Text.Json.JsonSerializer.Serialize(snapshots[0], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 2), System.Text.Json.JsonSerializer.Serialize(snapshots[1], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 3), System.Text.Json.JsonSerializer.Serialize(snapshots[2], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 4), score.ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, GetBossSid(bossLevelId, 5), Math.Max(0, (int)Math.Floor(finishTime)).ToString(CultureInfo.InvariantCulture), sync); + } + + private static BossPvpRoleSnapshot[] CaptureLineupSnapshots(PlayerInstance player, uint lineupId) + { + var lineups = player.LineupManager.LineupData.LineupInfo; + var lineup = lineups.GetValueOrDefault((int)lineupId) + ?? lineups.GetValueOrDefault((int)BossLineup1) + ?? lineups.GetValueOrDefault(1) + ?? lineups.Values.FirstOrDefault(); + + if (lineup == null) + { + return [new(), new(), new()]; + } + + return + [ + CaptureRoleSnapshot(player, lineup.Member1), + CaptureRoleSnapshot(player, lineup.Member2), + CaptureRoleSnapshot(player, lineup.Member3) + ]; + } + + private static BossPvpRoleSnapshot CaptureRoleSnapshot(PlayerInstance player, uint characterGuid) + { + if (characterGuid == 0) + { + return new BossPvpRoleSnapshot(); + } + + var character = player.CharacterManager.GetCharacterByGUID(characterGuid); + if (character == null) + { + return new BossPvpRoleSnapshot(); + } + + var snapshot = new BossPvpRoleSnapshot + { + Role = character.Guid, + Weapon = character.WeaponUniqueId + }; + + var weapon = player.InventoryManager.GetWeaponItem(character.WeaponUniqueId); + if (weapon != null) + { + snapshot.Wgdpl = BuildWeaponGdpl(weapon); + snapshot.Wslot = weapon.PartSlots; + } + + var supports = character.SupportSlots + .OrderBy(x => x.Key) + .Select(x => x.Value) + .Where(x => x != 0) + .Take(3) + .ToArray(); + + if (supports.Length > 0) + { + snapshot.S1 = supports[0]; + snapshot.Sgdpl1 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[0])); + } + + if (supports.Length > 1) + { + snapshot.S2 = supports[1]; + snapshot.Sgdpl2 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[1])); + } + + if (supports.Length > 2) + { + snapshot.S3 = supports[2]; + snapshot.Sgdpl3 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[2])); + } + + return snapshot; + } + + private static List BuildWeaponGdpl(GameWeaponInfo weapon) + { + var gdpl = DecodeGdpl(weapon.TemplateId); + gdpl.Add(weapon.Level); + gdpl.Add(weapon.Evolue); + return gdpl; + } + + private static List BuildSupportGdpl(GameSupportCardInfo? support) + { + if (support == null) + { + return []; + } + + var gdpl = DecodeGdpl(support.TemplateId); + gdpl.Add(support.Level); + gdpl.Add(0); + return gdpl; + } + + private static List DecodeGdpl(ulong templateId) + { + return + [ + (uint)(templateId & 0xFFFF), + (uint)((templateId >> 16) & 0xFFFF), + (uint)((templateId >> 32) & 0xFFFF), + (uint)((templateId >> 48) & 0xFFFF) + ]; + } + + private static int ComputeIntegral(uint bossLevelId, int diff, int residueTime) + { + if (!GameData.BossPvpBossData.TryGetValue(bossLevelId, out var boss) || diff <= 0 || diff > boss.BossLevel.Count) + { + return 0; + } + + var info = boss.BossLevel[diff - 1]; + if (info.Count == 0) + { + return 0; + } + + var multiplier = info.Count > 2 ? info[2] : 0; + var baseScore = info.Count > 3 ? info[3] : 0; + var residueScore = info.Count > 4 ? info[4] : 0; + var total = (baseScore + residueScore * Math.Max(0, residueTime)) * multiplier; + return (int)Math.Floor(total + 0.5); + } + + private static uint? TryGetPositionDiffSid(uint bossLevelId) + { + var season = GetOpenSeason(); + if (season == null) + { + return null; + } + + var index = season.BossIds.FindIndex(x => x == bossLevelId); + return index >= 0 ? DiffStartId + (uint)(index + 1) : null; + } + + private static BossPvpBossChallengeExcel? GetOpenSeason() + { + var now = DateTimeOffset.Now; + var current = GameData.BossPvpBossChallengeData.Values + .OrderBy(x => x.ID) + .FirstOrDefault(x => + { + var startAt = ParseBossTime(x.StartTime); + var endAt = ParseBossTime(x.EndTime); + return startAt != null && endAt != null && now >= startAt && now <= endAt; + }); + + return current ?? GameData.BossPvpBossChallengeData.Values.OrderBy(x => x.ID).FirstOrDefault(); + } + + private static uint GetDailyChallengeNum() + { + var now = DateTime.Now; + if (now.Hour < 4) + { + now = now.AddHours(-4); + } + + var week = now.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)now.DayOfWeek; + return GameData.BossPvpNumData.TryGetValue((uint)week, out var count) ? count.Num : 8; + } + + private static int ReadInt(PlayerInstance player, uint sid) + { + var attr = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid)?.Val; + return int.TryParse(attr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0; + } + + private static void EnsureStr(PlayerInstance player, uint sid, string value, NtfSyncPlayer sync) + { + var attr = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + { + return; + } + + SetStr(player, sid, value, sync); + } + + private static void SetStr(PlayerInstance player, uint sid, string value, NtfSyncPlayer sync) + { + player.SetStrAttr(GroupId, sid, value); + sync.CustomStr[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + + private static uint GetBossSid(uint bossLevelId, uint offset) => (LevelStride * bossLevelId) + LevelStartSid + offset; + + private static string EmptySnapshotJson() => System.Text.Json.JsonSerializer.Serialize(new BossPvpRoleSnapshot(), JsonOptions); + + private static T? Deserialize(string? param) + { + if (string.IsNullOrWhiteSpace(param)) + { + return default; + } + + return System.Text.Json.JsonSerializer.Deserialize(param, JsonOptions); + } + + private static DateTimeOffset? ParseBossTime(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var raw = value.Trim().Trim('[', ']'); + if (!DateTime.TryParseExact(raw, "yyyyMMddHHmm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var localTime)) + { + return null; + } + + return new DateTimeOffset(localTime); + } + + private sealed class EnterLevelParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + } + + private sealed class RecordParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + [JsonPropertyName("nTime")] public double NTime { get; set; } + [JsonPropertyName("ResidueTime")] public int ResidueTime { get; set; } + [JsonPropertyName("bRecord")] public bool BRecord { get; set; } + [JsonPropertyName("nTeamID")] public uint NTeamId { get; set; } + } + + private sealed class SettlementParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + [JsonPropertyName("nTime")] public double NTime { get; set; } + [JsonPropertyName("ResidueTime")] public int ResidueTime { get; set; } + [JsonPropertyName("nTeamID")] public uint NTeamId { get; set; } + } + + private sealed class FailParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + } + + private sealed class MopupParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + } + + private sealed class RewardParam + { + [JsonPropertyName("tbTaskID")] public List TaskIds { get; set; } = []; + } + + private sealed class BossPvpRoleSnapshot + { + [JsonPropertyName("role")] public uint Role { get; set; } + [JsonPropertyName("weapon")] public uint Weapon { get; set; } + [JsonPropertyName("s1")] public uint S1 { get; set; } + [JsonPropertyName("s2")] public uint S2 { get; set; } + [JsonPropertyName("s3")] public uint S3 { get; set; } + [JsonPropertyName("wgdpl")] public List Wgdpl { get; set; } = []; + [JsonPropertyName("wslot")] public Dictionary Wslot { get; set; } = []; + [JsonPropertyName("sgdpl1")] public List Sgdpl1 { get; set; } = []; + [JsonPropertyName("sgdpl2")] public List Sgdpl2 { get; set; } = []; + [JsonPropertyName("sgdpl3")] public List Sgdpl3 { get; set; } = []; + } +} diff --git a/GameServer/Game/Inventory/InventoryManager.cs b/GameServer/Game/Inventory/InventoryManager.cs index 5141eee..e5c3956 100644 --- a/GameServer/Game/Inventory/InventoryManager.cs +++ b/GameServer/Game/Inventory/InventoryManager.cs @@ -208,6 +208,27 @@ private static uint GetWeaponBreak(uint level) return InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); } + public async ValueTask AddMonsterCardItem(uint detail, uint particular, uint level = 1, bool sendPacket = true) + { + const ItemTypeEnum genre = ItemTypeEnum.TYPE_MONSTER_CARD; + var templateId = GameResourceTemplateId.FromGdpl((uint)genre, detail, particular, level); + if (!GameData.MonsterCardData.ContainsKey(templateId)) + return null; + + var monsterInfo = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = InventoryData.NextUniqueUid++, + ItemType = genre, + ItemCount = 1 + }; + InventoryData.Items[monsterInfo.UniqueId] = monsterInfo; + + if (sendPacket) await Player.SendPacket(new PacketNtfCallScript([monsterInfo])); + + return monsterInfo; + } + private static uint GetSuppliesMaxCount(SuppliesExcel suppliesData) => suppliesData.Genre == 5 && suppliesData.Detail == 4 ? 999999u : 99999u; @@ -365,4 +386,4 @@ private static uint GetSuppliesMaxCount(SuppliesExcel suppliesData) => return furnitureInfo; } -} \ No newline at end of file +} diff --git a/GameServer/Game/Player/PlayerInstance.cs b/GameServer/Game/Player/PlayerInstance.cs index 6a66e54..5aca457 100644 --- a/GameServer/Game/Player/PlayerInstance.cs +++ b/GameServer/Game/Player/PlayerInstance.cs @@ -19,6 +19,8 @@ namespace MikuSB.GameServer.Game.Player; public class PlayerInstance(PlayerGameData data) { + private const uint BootstrapLevel = 80; + #region Property public Connection? Connection { get; set; } @@ -50,35 +52,7 @@ public PlayerInstance(int uid) : this(new PlayerGameData { Uid = uid }) var t = Task.Run(async () => { await InitialPlayerManager(); - foreach (var skinCard in GameData.CardSkinData.Values) - { - await InventoryManager.AddSkinItem((ItemTypeEnum)skinCard.Genre, skinCard.Detail, skinCard.Particular, skinCard.Level, false); - } - foreach (var ar in GameData.ArItemData.Values) - { - await InventoryManager.AddArItem((ItemTypeEnum)ar.Genre, ar.Detail, ar.Particular, ar.Level, false); - } - foreach (var manifest in GameData.ManifestationData.Values) - { - await InventoryManager.AddManifestationItem((ItemTypeEnum)manifest.Genre, manifest.Detail, manifest.Particular, manifest.Level, false); - } - foreach (var card in GameData.CardData.Values) - { - await CharacterManager.AddCharacter((ItemTypeEnum)card.Genre, card.Detail, card.Particular, card.Level, sendPacket:false); - } - foreach (var supplies in GameData.AllSuppliesData) - { - await InventoryManager.AddSuppliesItem(supplies, 90000, false); - } - - var selected = CharacterManager.CharacterData.Characters - .OrderBy(_ => Guid.NewGuid()) - .Take(3) - .Select(x => x.Guid) - .ToList(); - - await LineupManager.UpdateLineup(1, selected[0], selected[1], selected[2],false); - + await InitializeAllDatabaseData(); }); t.Wait(); @@ -106,6 +80,7 @@ private async ValueTask InitialPlayerManager() public async ValueTask OnEnterGame() { if (!Initialized) await InitialPlayerManager(); + if (ShouldBackfillAllDatabaseData()) await InitializeAllDatabaseData(); Data.EnsureDisplayName(); await CharacterManager.RepairCharacterWeapons(); await EnsureSupplies(); @@ -145,6 +120,117 @@ public async ValueTask SendPacket(int cmdId, IMessage msg) #endregion + private bool ShouldBackfillAllDatabaseData() + { + if (CharacterManager.CharacterData.Characters.Count > 0) + return false; + + var inventoryData = InventoryManager.InventoryData; + return inventoryData.Items.Count == 0 + && inventoryData.Weapons.Count == 0 + && inventoryData.Skins.Count == 0 + && inventoryData.SupportCards.Count == 0; + } + + private async ValueTask InitializeAllDatabaseData() + { + foreach (var weapon in GameData.WeaponData.Values) + { + if (weapon.Level <= 0) + continue; + + await InventoryManager.AddWeaponItem((ItemTypeEnum)weapon.Genre, weapon.Detail, weapon.Particular, + weapon.Level, BootstrapLevel, false); + } + foreach (var supportCard in GameData.SupportCardData) + { + if (supportCard.Level <= 0) + continue; + + await InventoryManager.AddSupportCardItem(supportCard.Detail, supportCard.Particular, supportCard.Level, BootstrapLevel, false); + } + foreach (var weaponSkin in GameData.WeaponSkinData.Values) + { + if (weaponSkin.Level <= 0) + continue; + + await InventoryManager.AddWeaponSkinItem((ItemTypeEnum)weaponSkin.Genre, weaponSkin.Detail, weaponSkin.Particular, weaponSkin.Level, false); + } + foreach (var skinCard in GameData.CardSkinData.Values) + { + if (skinCard.Level <= 0) + continue; + + await InventoryManager.AddSkinItem((ItemTypeEnum)skinCard.Genre, skinCard.Detail, skinCard.Particular, skinCard.Level, false); + } + foreach (var profile in GameData.ProfileData.Values) + { + if (profile.Level <= 0) + continue; + + await InventoryManager.AddProfileItem((ItemTypeEnum)profile.Genre, profile.Detail, profile.Particular, profile.Level, false); + } + foreach (var skinPart in GameData.CardSkinPartsData.Values) + { + if (skinPart.Level <= 0) + continue; + + await InventoryManager.AddSkinPartItem((ItemTypeEnum)skinPart.Genre, skinPart.Detail, skinPart.Particular, skinPart.Level, false); + } + foreach (var callItem in GameData.CallItemData.Values) + { + if (callItem.Level <= 0) + continue; + + await InventoryManager.AddCallItem((ItemTypeEnum)callItem.Genre, callItem.Detail, callItem.Particular, callItem.Level, false); + } + foreach (var weaponPart in GameData.WeaponPartsData.Values) + { + if (weaponPart.Level <= 0) + continue; + + await InventoryManager.AddWeaponPartItem((ItemTypeEnum)weaponPart.Genre, weaponPart.Detail, weaponPart.Particular, weaponPart.Level, false); + } + foreach (var furniture in GameData.DormGiftData.Values) + { + if (furniture.Level <= 0) + continue; + + await InventoryManager.AddHouseFurnitureItem((ItemTypeEnum)furniture.Genre, furniture.Detail, furniture.Particular, furniture.Level, false); + } + foreach (var ar in GameData.ArItemData.Values) + { + await InventoryManager.AddArItem((ItemTypeEnum)ar.Genre, ar.Detail, ar.Particular, ar.Level, false); + } + foreach (var manifest in GameData.ManifestationData.Values) + { + await InventoryManager.AddManifestationItem((ItemTypeEnum)manifest.Genre, manifest.Detail, manifest.Particular, manifest.Level, false); + } + foreach (var card in GameData.CardData.Values) + { + var character = await CharacterManager.AddCharacter((ItemTypeEnum)card.Genre, card.Detail, card.Particular, card.Level, sendPacket: false); + if (character == null) + continue; + + character.Level = BootstrapLevel; + } + foreach (var supplies in GameData.AllSuppliesData) + { + await InventoryManager.AddSuppliesItem(supplies, 90000, false); + } + + if (!LineupManager.LineupData.LineupInfo.ContainsKey(1)) + { + var selected = CharacterManager.CharacterData.Characters + .OrderBy(_ => Guid.NewGuid()) + .Take(3) + .Select(x => x.Guid) + .ToList(); + if (selected.Count == 3) + await LineupManager.UpdateLineup(1, selected[0], selected[1], selected[2], false); + } + } + #region Actions public async ValueTask OnHeartBeat() { @@ -196,7 +282,7 @@ public PlayerProfile ToServerFriendProto() return proto; } - public Proto.Player ToPlayerProto() + public Proto.Player ToPlayerProto(bool includeSupportCards = true) { BuildPlayerAttr(); var displayName = PlayerGameData.NormalizeDisplayName(Data.Name); @@ -205,6 +291,8 @@ public Proto.Player ToPlayerProto() Pid = (ulong)Data.Uid, Account = displayName, Provider = displayName, + Channel = "gm", + Subchannel = "gm", Name = displayName, Level = Data.Level, Sex = Data.Gender, @@ -217,7 +305,10 @@ public Proto.Player ToPlayerProto() foreach (var item in InventoryManager.InventoryData.Items.Values) proto.Items.Add(item.ToProto()); foreach (var skin in InventoryManager.InventoryData.Skins.Values) proto.Items.Add(skin.ToProto()); foreach (var weapon in InventoryManager.InventoryData.Weapons.Values) proto.Items.Add(weapon.ToProto()); - foreach (var card in InventoryManager.InventoryData.SupportCards.Values) proto.Items.Add(card.ToProto()); + if (includeSupportCards) + { + foreach (var card in InventoryManager.InventoryData.SupportCards.Values) proto.Items.Add(card.ToProto()); + } foreach (var x in Data.Attrs) { uint gid = x.Gid; @@ -239,6 +330,11 @@ public Proto.Player ToPlayerProto() proto.StrAttrs[ToShiftedAttrKey(x.Gid, x.Sid)] = x.Val; } + foreach (var (key, value) in BuildMoneySync()) + { + proto.Money[key] = value; + } + proto.ShowItems.AddRange(Data.ShowItems); return proto; @@ -292,6 +388,24 @@ public uint ToShiftedAttrKey(uint gid, uint sid) return (gid << 16) | sid; } + public Dictionary BuildMoneySync() + { + var currentMoney = (int)Math.Min(int.MaxValue, GetAttrValue(1, 3)); + var sync = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["."] = currentMoney, + ["gm.gm"] = currentMoney, + ["jinshan.jinshan"] = currentMoney, + ["pc_jinshan.pc_jinshan"] = currentMoney + }; + return sync; + } + + private uint GetAttrValue(uint gid, uint sid) + { + return Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + public void BuildPlayerAttr(bool additional = false) { var bootstrapAttrs = BuildLobbyBootstrapAttrs().ToList(); @@ -422,6 +536,14 @@ public void BuildPlayerAttr(bool additional = false) yield return (22, levelId, 1_700_000_000); } + // Role fragment chapters use Condition.PRE_LEVEL against Launch.GPASSID as well. + // Mark every role level as cleared so character-specific stages beyond the first one unlock. + foreach (var levelId in GameData.RoleLevelData.Keys) + { + yield return (21, levelId, 7); + yield return (22, levelId, 1_700_000_000); + } + foreach (var guide in GameData.GuideData.Values) { yield return (4, guide.ID, 999); @@ -434,4 +556,4 @@ public void BuildPlayerAttr(bool additional = false) yield return (132, 1, 0); } #endregion -} \ No newline at end of file +} diff --git a/GameServer/Server/CallGS/CallGSRouter.cs b/GameServer/Server/CallGS/CallGSRouter.cs index 6ea0281..e3cadb3 100644 --- a/GameServer/Server/CallGS/CallGSRouter.cs +++ b/GameServer/Server/CallGS/CallGSRouter.cs @@ -8,6 +8,7 @@ public static class CallGSRouter { private static readonly Logger Logger = new("CallGS"); private static readonly Dictionary Handlers = []; + private const string UnavailableTipKey = "ui.TxtNotOpen"; public static void Init() { @@ -32,11 +33,13 @@ public static async Task Route(Connection connection, ReqCallGS req, ushort seqN catch (Exception e) { Logger.Error($"[{req.Api}] {e.Message}", e); + await SendUnavailableResponse(connection, req.Api); } return; } Logger.Error($"No handler for CallGS API: {req.Api}"); + await SendUnavailableResponse(connection, req.Api); } public static async Task SendScript(Connection connection, string api, string arg, NtfSyncPlayer extra = null!) @@ -44,4 +47,11 @@ public static async Task SendScript(Connection connection, string api, string ar var rsp = new NtfCallScript { Api = api, Arg = arg, ExtraSync = extra }; await connection.SendPacket(CmdIds.NtfScript, rsp); } + + private static Task SendUnavailableResponse(Connection connection, string api) + { + // Many client Lua handlers treat sErr/sError as a recoverable failure path, + // which is preferable to leaving the request hanging forever. + return SendScript(connection, api, $$"""{"sErr":"{{UnavailableTipKey}}","sError":"{{UnavailableTipKey}}"}"""); + } } diff --git a/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs new file mode 100644 index 0000000..46d6f1f --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs @@ -0,0 +1,113 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BattlePass; + +[CallGSApi("BattlePassLogic_ClientRefresh")] +public class BattlePassLogic_ClientRefresh : ICallGSHandler +{ + private const uint GroupId = 25; + private const uint CurIdSid = 1; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var battlePass = ResolveCurrent(GameData.BattlePassTimeData.Values, now); + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + if (battlePass == null) + { + SetAttr(player, CurIdSid, 0, sync); + await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", "{}", sync); + return; + } + + SetAttr(player, CurIdSid, battlePass.Id, sync); + + var response = new JsonObject + { + ["nId"] = battlePass.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(battlePass.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(battlePass.EndTime)) + }; + + await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", response.ToJsonString(), sync); + } + + private static BattlePassTimeExcel? ResolveCurrent(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; + } + + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs new file mode 100644 index 0000000..2a011e2 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_EnterLevel")] +public class BossPvpLogic_EnterLevel : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var response = BossPvpService.HandleEnterLevel(param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_EnterLevel", System.Text.Json.JsonSerializer.Serialize(response)); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs new file mode 100644 index 0000000..aa959a0 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_GetOpenID")] +public class BossPvpLogic_GetOpenID : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = await BossPvpService.HandleGetOpenIdAsync(connection.Player!); + await CallGSRouter.SendScript(connection, "BossPvpLogic_GetOpenID", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs new file mode 100644 index 0000000..bba3a86 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_GetReward")] +public class BossPvpLogic_GetReward : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var response = BossPvpService.HandleGetReward(param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_GetReward", System.Text.Json.JsonSerializer.Serialize(response)); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs new file mode 100644 index 0000000..a44e10d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs @@ -0,0 +1,14 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelFail")] +public class BossPvpLogic_LevelFail : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var node = System.Text.Json.Nodes.JsonNode.Parse(param); + var (response, sync) = BossPvpService.HandleFail(connection.Player!, node); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelFail", response.ToJsonString(), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs new file mode 100644 index 0000000..10ec06b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelMopup")] +public class BossPvpLogic_LevelMopup : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = BossPvpService.HandleMopup(connection.Player!, param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelMopup", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs new file mode 100644 index 0000000..a7f33d4 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs @@ -0,0 +1,14 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelSettlement")] +public class BossPvpLogic_LevelSettlement : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var node = System.Text.Json.Nodes.JsonNode.Parse(param); + var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, node); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelSettlement", response.ToJsonString(), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs new file mode 100644 index 0000000..d110b84 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_Record")] +public class BossPvpLogic_Record : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = BossPvpService.HandleRecord(connection.Player!, param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_Record", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs index 5237ae1..4434d6e 100644 --- a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs +++ b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs @@ -1,6 +1,11 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using MikuSB.GameServer.Game.BossPvp; +using MikuSB.Proto; +using MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; +using MikuSB.GameServer.Server.CallGS.Handlers.Tower; +using MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; namespace MikuSB.GameServer.Server.CallGS.Handlers.Chapter; @@ -10,17 +15,22 @@ public class Chapter_DealLevelSettlement : ICallGSHandler public async Task Handle(Connection connection, string param, ushort seqNo) { var req = JsonSerializer.Deserialize(param); + NtfSyncPlayer? extraSync = null; var response = new JsonObject { ["sCmd"] = req?.SCmd ?? "Chapter_LevelSettlement", - ["tbParam"] = BuildSettlementPayload(req?.SCmd, req?.TbParam) + ["tbParam"] = BuildSettlementPayload(connection, req?.SCmd, req?.TbParam, out extraSync) }; - await CallGSRouter.SendScript(connection, "Chapter_DealLevelSettlement", response.ToJsonString()); + await CallGSRouter.SendScript(connection, "Chapter_DealLevelSettlement", response.ToJsonString(), extraSync!); } - private static JsonNode BuildSettlementPayload(string? sCmd, JsonNode? tbParam) + private static JsonNode BuildSettlementPayload(Connection connection, string? sCmd, JsonNode? tbParam, out NtfSyncPlayer? extraSync) { + extraSync = null; + + extraSync = null; + if (string.Equals(sCmd, "Chapter_LevelSettlement", StringComparison.Ordinal)) { return new JsonArray(); @@ -37,8 +47,67 @@ private static JsonNode BuildSettlementPayload(string? sCmd, JsonNode? tbParam) return result; } + if (string.Equals(sCmd, "BossPvpLogic_LevelSettlement", StringComparison.Ordinal)) + { + var normalized = NormalizeBossPvpSettlement(tbParam); + var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, normalized); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "BossPvpLogic_LevelFail", StringComparison.Ordinal)) + { + var (response, sync) = BossPvpService.HandleFail(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "TowerLevel_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = TowerLevel_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "TowerEventChapter_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = TowerEventChapter_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "VirCaptureTower_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = VirCaptureTower_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "DreamCard_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = DreamCard_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + return tbParam?.DeepClone() ?? new JsonObject(); } + + private static JsonNode? NormalizeBossPvpSettlement(JsonNode? tbParam) + { + if (tbParam is not JsonObject obj) + return tbParam; + + var clone = obj.DeepClone() as JsonObject ?? obj; + if (clone.TryGetPropertyValue("ResidueTime", out var residueNode) && + residueNode is JsonValue residueValue && + residueValue.TryGetValue(out var residueTime)) + { + clone["ResidueTime"] = (int)Math.Max(0, Math.Round(residueTime, MidpointRounding.AwayFromZero)); + } + + return clone; + } } internal sealed class DealLevelSettlementParam diff --git a/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs b/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs new file mode 100644 index 0000000..b5b7958 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs @@ -0,0 +1,112 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DLC; + +[CallGSApi("DLCLogic_CheckOpenAct")] +public class DLCLogic_CheckOpenAct : ICallGSHandler +{ + private const uint GroupId = 15; + private const uint ActIdSid = 1; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var act = ResolveCurrent(GameData.DlcActivityData.Values, now); + if (act == null) + { + await CallGSRouter.SendScript(connection, "DLCLogic_CheckOpenAct", "{\"bOpen\":false}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + SetAttr(player, ActIdSid, act.Id, sync); + + var response = new JsonObject + { + ["bOpen"] = true, + ["nId"] = act.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(act.EnterStartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(act.CloseEndTime)) + }; + + await CallGSRouter.SendScript(connection, "DLCLogic_CheckOpenAct", response.ToJsonString(), sync); + } + + private static DlcActivityExcel? ResolveCurrent(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.EnterStartTime), + End = ParseConfigTime(x.CloseEndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; + } + + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs new file mode 100644 index 0000000..20cbe07 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs @@ -0,0 +1,59 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_CheckOpen")] +public class DreamCard_CheckOpen : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var ids = GameData.DreamCardActivityData.Values + .Where(x => IsOpen(x, now)) + .OrderBy(x => x.ID) + .Select(x => JsonValue.Create(x.ID)) + .ToArray(); + + var response = new JsonObject + { + ["tbID"] = new JsonArray(ids) + }; + + await CallGSRouter.SendScript(connection, "DreamCard_CheckOpen", response.ToJsonString()); + } + + private static bool IsOpen(DreamCardActivityExcel config, DateTime now) + { + var start = ParseConfigTime(config.StartTime); + if (!start.HasValue || start > now) + return false; + + var end = ParseConfigTime(config.EndTime); + if (end.HasValue && now >= end.Value) + return false; + + return string.IsNullOrWhiteSpace(config.Condition); + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs new file mode 100644 index 0000000..a829217 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs @@ -0,0 +1,227 @@ +using MikuSB.Data; +using MikuSB.Util; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_EnterLevel")] +public class DreamCard_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + private static readonly Lazy LevelIndex = new(LoadLevelIndex); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId <= 0 || req.Diff <= 0 || req.Type is < 1 or > 3) + { + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", "null"); + return; + } + + var now = DateTime.Now; + if (!IsAllowed(req, now)) + { + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", "null"); + return; + } + + var response = new JsonObject + { + ["nSeed"] = Random.Next(1, 1_000_000_000), + ["nID"] = req.LevelId, + ["nDiff"] = req.Diff, + ["nType"] = req.Type + }; + + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", response.ToJsonString()); + } + + private static bool IsAllowed(DreamCardEnterLevelParam req, DateTime now) + { + var index = LevelIndex.Value; + if (index == null) + return true; + + return req.Type switch + { + 1 => index.OpenOrdinaryLevelIds(now).Contains((uint)req.LevelId), + 2 => index.IsChallengeOpen((uint)req.LevelId, now), + 3 => index.IsEndlessOpen((uint)req.LevelId, now), + _ => false + }; + } + + private static DreamCardLevelIndex? LoadLevelIndex() + { + try + { + var resourceRoot = ConfigManager.Config.Path.ResourcePath; + var dreamCardRoot = Path.Combine(resourceRoot, "dlc", "DreamCard"); + + var ordinaryLevels = LoadJson>(Path.Combine(dreamCardRoot, "levellist.json")) ?? []; + var challengeLevels = LoadJson>(Path.Combine(dreamCardRoot, "challenge.json")) ?? []; + var endlessLevels = LoadJson>(Path.Combine(dreamCardRoot, "endless.json")) ?? []; + + return new DreamCardLevelIndex(ordinaryLevels, challengeLevels, endlessLevels); + } + catch + { + return null; + } + } + + private static T? LoadJson(string path) + { + if (!File.Exists(path)) + return default; + + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } +} + +internal sealed class DreamCardEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nDiff")] + public int Diff { get; set; } + + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nRoleId")] + public int RoleId { get; set; } +} + +internal sealed class DreamCardLevelIndex +{ + private readonly HashSet ordinaryLevelIds; + private readonly Dictionary challengeLevels; + private readonly Dictionary endlessLevels; + + public DreamCardLevelIndex( + IEnumerable ordinaryLevels, + IEnumerable challengeLevels, + IEnumerable endlessLevels) + { + ordinaryLevelIds = ordinaryLevels + .Where(x => x.LevelListId > 0) + .Select(x => x.LevelListId) + .ToHashSet(); + + this.challengeLevels = challengeLevels + .Where(x => x.ChallengeId > 0) + .GroupBy(x => x.ChallengeId) + .ToDictionary(x => x.Key, x => x.First()); + + this.endlessLevels = endlessLevels + .Where(x => x.EndlessId > 0) + .GroupBy(x => x.EndlessId) + .ToDictionary(x => x.Key, x => x.First()); + } + + public HashSet OpenOrdinaryLevelIds(DateTime now) + { + var ids = new HashSet(); + foreach (var activity in GameData.DreamCardActivityData.Values) + { + if (!IsActivityOpen(activity, now)) + continue; + + foreach (var id in activity.LevelListID) + { + if (ordinaryLevelIds.Contains(id)) + ids.Add(id); + } + } + + return ids; + } + + public bool IsChallengeOpen(uint id, DateTime now) + { + return challengeLevels.TryGetValue(id, out var entry) && IsWithin(entry.StartTime, entry.EndTime, now); + } + + public bool IsEndlessOpen(uint id, DateTime now) + { + return endlessLevels.TryGetValue(id, out var entry) && IsWithin(entry.StartTime, entry.EndTime, now); + } + + private static bool IsActivityOpen(Data.Excel.DreamCardActivityExcel config, DateTime now) + { + var start = ParseConfigTime(config.StartTime); + if (!start.HasValue || start > now) + return false; + + var end = ParseConfigTime(config.EndTime); + if (end.HasValue && now >= end.Value) + return false; + + return string.IsNullOrWhiteSpace(config.Condition); + } + + private static bool IsWithin(string? startRaw, string? endRaw, DateTime now) + { + var start = ParseConfigTime(startRaw); + if (!start.HasValue || now < start.Value) + return false; + + var end = ParseConfigTime(endRaw); + return !end.HasValue || now < end.Value; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class DreamCardOrdinaryLevelEntry +{ + [JsonPropertyName("LevelListID")] + public uint LevelListId { get; set; } +} + +internal sealed class DreamCardChallengeLevelEntry +{ + [JsonPropertyName("ChallengeId")] + public uint ChallengeId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} + +internal sealed class DreamCardEndlessLevelEntry +{ + [JsonPropertyName("EndlessID")] + public uint EndlessId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs new file mode 100644 index 0000000..b407d39 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs @@ -0,0 +1,281 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_LevelSettlement")] +public class DreamCard_LevelSettlement : ICallGSHandler +{ + private const uint LevelGroupId = 152; + private const uint LevelSubNum = 10; + private const int OrdinaryType = 1; + private const int ChallengeType = 2; + private const int EndlessType = 3; + + private static readonly Lazy SettlementIndex = new(LoadIndex); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "DreamCard_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonObject Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId <= 0 || req.Diff <= 0 || req.Type is < OrdinaryType or > EndlessType) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var sync = new NtfSyncPlayer(); + var response = new JsonObject + { + ["nID"] = req.LevelId, + ["nDiff"] = req.Diff, + ["nType"] = req.Type + }; + + switch (req.Type) + { + case OrdinaryType: + HandleOrdinary(player, sync, response, req); + break; + case ChallengeType: + HandleChallenge(player, sync, response, req); + break; + case EndlessType: + HandleEndless(response, req); + break; + } + + DatabaseHelper.SaveDatabaseType(player.Data); + return (response, sync); + } + + private static void HandleOrdinary(PlayerInstance player, NtfSyncPlayer sync, JsonObject response, DreamCardLevelSettlementParam req) + { + var baseSid = (uint)(LevelSubNum * req.LevelId); + + var passAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 1); + passAttr.Val += 1; + SyncAttr(sync, player, passAttr); + + var diffAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 2); + diffAttr.Val = Math.Max(diffAttr.Val, (uint)req.Diff); + SyncAttr(sync, player, diffAttr); + + var starAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 3); + starAttr.Val = MergeDifficultyBits(starAttr.Val, req.Diff, req.StarValue); + SyncAttr(sync, player, starAttr); + + if (TryGetOrdinaryRewardId((uint)req.LevelId, (uint)req.Diff, out var rewardId) && rewardId > 0) + response["nRewardID"] = rewardId; + } + + private static void HandleChallenge(PlayerInstance player, NtfSyncPlayer sync, JsonObject response, DreamCardLevelSettlementParam req) + { + var baseSid = (uint)(LevelSubNum * req.LevelId); + var scoreSid = baseSid + (uint)req.Diff + 4; + + var currentScore = (uint)Math.Max(0, req.Score); + var scoreAttr = GetOrCreateAttr(player.Data, LevelGroupId, scoreSid); + var newRecord = currentScore > scoreAttr.Val; + scoreAttr.Val = Math.Max(scoreAttr.Val, currentScore); + SyncAttr(sync, player, scoreAttr); + + var challengePeriodId = ResolveCurrentChallengePeriodId(DateTime.Now); + if (challengePeriodId > 0) + { + var periodAttr = GetOrCreateAttr(player.Data, LevelGroupId, 0); + periodAttr.Val = challengePeriodId; + SyncAttr(sync, player, periodAttr); + } + + response["NewRecord"] = newRecord; + } + + private static void HandleEndless(JsonObject response, DreamCardLevelSettlementParam req) + { + response["NewRecord"] = false; + } + + private static uint MergeDifficultyBits(uint currentValue, int diff, int starMask) + { + var bitStart = Math.Max(0, diff - 1) * 3; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + result |= 1u << (bitStart + i); + } + + return result; + } + + private static bool TryGetOrdinaryRewardId(uint levelId, uint diff, out uint rewardId) + { + rewardId = 0; + var index = SettlementIndex.Value; + if (index == null) + return false; + + return index.TryGetOrdinaryRewardId(levelId, diff, out rewardId); + } + + private static uint ResolveCurrentChallengePeriodId(DateTime now) + { + var index = SettlementIndex.Value; + return index?.ResolveCurrentChallengePeriodId(now) ?? 0; + } + + private static DreamCardSettlementIndex? LoadIndex() + { + try + { + var root = Path.Combine(MikuSB.Util.ConfigManager.Config.Path.ResourcePath, "dlc", "DreamCard"); + var ordinaryLevels = LoadJson>(Path.Combine(root, "levellist.json")) ?? []; + var challengeTimes = LoadJson>(Path.Combine(root, "chall_time.json")) ?? []; + return new DreamCardSettlementIndex(ordinaryLevels, challengeTimes); + } + catch + { + return null; + } + } + + private static T? LoadJson(string path) + { + if (!File.Exists(path)) + return default; + + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class DreamCardLevelSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nDiff")] + public int Diff { get; set; } + + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nStarValue")] + public int StarValue { get; set; } + + [JsonPropertyName("nScore")] + public int Score { get; set; } +} + +internal sealed class DreamCardSettlementIndex +{ + private readonly Dictionary<(uint LevelId, uint Diff), uint> ordinaryRewardIds; + private readonly List challengeTimes; + + public DreamCardSettlementIndex( + IEnumerable ordinaryLevels, + IEnumerable challengeTimes) + { + ordinaryRewardIds = ordinaryLevels + .Where(x => x.LevelListId > 0 && x.HardStage > 0) + .GroupBy(x => (x.LevelListId, x.HardStage)) + .ToDictionary(x => x.Key, x => x.First().RewardId); + + this.challengeTimes = challengeTimes.ToList(); + } + + public bool TryGetOrdinaryRewardId(uint levelId, uint diff, out uint rewardId) + { + return ordinaryRewardIds.TryGetValue((levelId, diff), out rewardId); + } + + public uint ResolveCurrentChallengePeriodId(DateTime now) + { + foreach (var entry in challengeTimes.OrderBy(x => x.ChallTimeId)) + { + var start = ParseConfigTime(entry.StartTime); + var end = ParseConfigTime(entry.EndTime); + if (!start.HasValue || !end.HasValue) + continue; + + if (start.Value <= now && now < end.Value) + return entry.ChallTimeId; + } + + return 0; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class DreamCardOrdinarySettlementEntry +{ + [JsonPropertyName("LevelListID")] + public uint LevelListId { get; set; } + + [JsonPropertyName("HardStage")] + public uint HardStage { get; set; } + + [JsonPropertyName("RewardID")] + public uint RewardId { get; set; } +} + +internal sealed class DreamCardChallengeTimeEntry +{ + [JsonPropertyName("ChallTimeID")] + public uint ChallTimeId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs new file mode 100644 index 0000000..7498fdb --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs @@ -0,0 +1,59 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_UpdateData")] +public class DreamCard_UpdateData : ICallGSHandler +{ + private const uint DataGroupId = 62; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var dirty = false; + + try + { + var entries = JsonSerializer.Deserialize>(param) ?? []; + foreach (var entry in entries) + { + if (entry.Id <= 0) + continue; + + var value = NormalizeJson(entry.Data); + player.SetStrAttr(DataGroupId, (uint)entry.Id, value); + sync.CustomStr[player.ToShiftedAttrKey(DataGroupId, (uint)entry.Id)] = value; + dirty = true; + } + } + catch + { + // Ignore malformed payloads so the client-side save queue can continue. + } + + if (dirty) + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "DreamCard_UpdateData", "{}", sync); + } + + private static string NormalizeJson(JsonElement data) + { + return data.ValueKind == JsonValueKind.Undefined + ? "null" + : data.GetRawText(); + } +} + +internal sealed class DreamCardUpdateDataEntry +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("data")] + public JsonElement Data { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs b/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs new file mode 100644 index 0000000..1597cf8 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs @@ -0,0 +1,245 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Fishing; + +[CallGSApi("FishingServer_ConvertFood")] +public class FishingServer_ConvertFood : ICallGSHandler +{ + private const uint FishingGroupId = 32; + private const uint CashGroupId = 1; + private const uint FoodBaseSid = 30000; + private const uint FoodAvaTimeSubType = 1; + private const uint ExploreAvaTimeSubType = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.FoodId <= 0 || req.Num <= 0) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + if (!GameData.FishingFoodData.TryGetValue((uint)req.FoodId, out var food)) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + var count = Math.Max(1u, req.Num); + var sync = new NtfSyncPlayer(); + + if (!HasEnoughMaterials(player.InventoryManager.InventoryData, food.NeedItem, count) || + !HasEnoughCash(player.Data, food.BaitNum, count)) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"tip.girlcard_cmd_err\"}"); + return; + } + + ConsumeMaterials(player.InventoryManager.InventoryData, food.NeedItem, count, sync.Items); + ConsumeCash(player, food.BaitNum, count, sync); + + var response = new JsonObject + { + ["nFoodID"] = req.FoodId + }; + + switch (food.FoodType) + { + case 1: + ApplyFoodDuration(player, food, FoodAvaTimeSubType, count, sync); + break; + case 2: + { + var rewards = await CreateItemsAsync(player, sync, food.CreateItems, count); + response["tbBait"] = rewards; + break; + } + case 3: + ApplyFoodDuration(player, food, ExploreAvaTimeSubType, count, sync); + break; + default: + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", response.ToJsonString(), sync); + } + + private static bool HasEnoughMaterials(InventoryData inventory, IEnumerable> costs, uint multiplier) + { + foreach (var cost in costs) + { + if (cost.Count < 5) + return false; + + var templateId = GameResourceTemplateId.FromGdpl(cost[0], cost[1], cost[2], cost[3]); + var item = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + var needCount = checked(cost[4] * multiplier); + if (item == null || item.ItemCount < needCount) + return false; + } + + return true; + } + + private static void ConsumeMaterials(InventoryData inventory, IEnumerable> costs, uint multiplier, ICollection syncItems) + { + foreach (var cost in costs) + { + var templateId = GameResourceTemplateId.FromGdpl(cost[0], cost[1], cost[2], cost[3]); + var item = inventory.Items.Values.First(x => x.TemplateId == templateId); + var needCount = checked(cost[4] * multiplier); + item.ItemCount -= needCount; + + if (item.ItemCount == 0) + { + inventory.Items.Remove(item.UniqueId); + var proto = item.ToProto(); + proto.Count = 0; + syncItems.Add(proto); + } + else + { + syncItems.Add(item.ToProto()); + } + } + } + + private static bool HasEnoughCash(PlayerGameData data, IReadOnlyList baitNum, uint multiplier) + { + if (baitNum.Count < 2) + return true; + + var moneyType = baitNum[0]; + var need = checked(baitNum[1] * multiplier); + var sid = moneyType * 2 + 1; + var attr = data.Attrs.FirstOrDefault(x => x.Gid == CashGroupId && x.Sid == sid); + return (attr?.Val ?? 0) >= need; + } + + private static void ConsumeCash(MikuSB.GameServer.Game.Player.PlayerInstance player, IReadOnlyList baitNum, uint multiplier, NtfSyncPlayer sync) + { + if (baitNum.Count < 2) + return; + + var moneyType = baitNum[0]; + var sid = moneyType * 2 + 1; + var need = checked(baitNum[1] * multiplier); + var attr = GetOrCreateAttr(player.Data, CashGroupId, sid); + attr.Val -= need; + SyncAttr(player, sync, attr); + } + + private static void ApplyFoodDuration(MikuSB.GameServer.Game.Player.PlayerInstance player, FishingFoodExcel food, uint subType, uint count, NtfSyncPlayer sync) + { + var sid = FoodBaseSid + food.Id * 10 + subType; + var attr = GetOrCreateAttr(player.Data, FishingGroupId, sid); + var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var startTime = Math.Max(attr.Val, now); + attr.Val = checked(startTime + food.EffectTime * count); + SyncAttr(player, sync, attr); + } + + private static async Task CreateItemsAsync(MikuSB.GameServer.Game.Player.PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList createItem, uint multiplier) + { + var rewards = new JsonArray(); + if (createItem.Count < 5) + return rewards; + + var itemType = (ItemTypeEnum)createItem[0]; + var detail = createItem[1]; + var particular = createItem[2]; + var level = createItem[3]; + var totalCount = checked(createItem[4] * multiplier); + + switch (itemType) + { + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(createItem[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, totalCount, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, detail, particular, level, totalCount); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + } + + rewards.Add(new JsonArray((int)createItem[0], (int)detail, (int)particular, (int)level, (int)totalCount)); + return rewards; + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl((uint)ItemTypeEnum.TYPE_USEABLE, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr { Gid = gid, Sid = sid, Val = 0 }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class FishingConvertFoodParam +{ + [JsonPropertyName("nFoodID")] + public int FoodId { get; set; } + + [JsonPropertyName("nNum")] + public uint Num { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs new file mode 100644 index 0000000..98d82a0 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs @@ -0,0 +1,552 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using Newtonsoft.Json.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Gacha; + +[CallGSApi("Gacha_Launch")] +public class Gacha_Launch : ICallGSHandler +{ + private const uint GachaGid = 5; + private const uint GachaSgid = 42; + private const uint SidTotalTime = 1; + private const uint SidDailyTotalTime = 2; + private const uint Interval = 10; + private const uint SidTimeInheritStart = 20000; + private const uint SidTimeNotInheritStart = 10; + private const uint SidAddTimeItem = 1; + private const uint SidAddTimeProb = 2; + private const uint SidAddProtectType = 3; + private const uint SidAddTotalTime = 7; + private const int UpSelectIndex = 0; + private const int UpSelectGetFlagIndex = 1; + private static readonly Random Rng = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.NId == 0 || req.NTime is not (1 or 10)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.GachaData.TryGetValue((uint)req.NId, out var gachaCfg)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var poolNames = (gachaCfg.Pool ?? []) + .Where(GameData.GachaPoolData.ContainsKey) + .ToList(); + var allPoolItems = poolNames + .SelectMany(p => GameData.GachaPoolData[p]) + .ToList(); + + if (allPoolItems.Count == 0 || !GameData.GachaProbabilityData.TryGetValue(gachaCfg.Probability, out var baseProbCfg)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var pityState = LoadPityState(player, gachaCfg); + var upSelectState = LoadUpSelectState(player, gachaCfg); + var config = BuildRuntimeConfig(gachaCfg, poolNames); + var awards = new List>(); + var tbNew = new List(); + var tbTrigger = new List(); + var syncItems = new List(); + var sync = new NtfSyncPlayer(); + + for (int i = 0; i < req.NTime; i++) + { + var forceTopUp = config.UpTarget != null && pityState.ProtectType == 2; + var hitHardPity = config.ProtectThreshold > 0 && pityState.ItemCount + 1 >= config.ProtectThreshold; + var useTenGuarantee = gachaCfg.ProbabilityTen != 0 + && pityState.TenCount + 1 >= 10 + && !HasGuaranteedTenRarity(config, awards); + + GachaProbabilityExcel probCfg = baseProbCfg; + if (useTenGuarantee && GameData.GachaProbabilityData.TryGetValue(gachaCfg.ProbabilityTen, out var tenProbCfg)) + probCfg = tenProbCfg; + + GachaPoolItem? item; + bool trigger = false; + + if (hitHardPity) + { + item = PickGuaranteedItem(gachaCfg, config, preferUp: forceTopUp); + trigger = item != null; + } + else + { + var rarity = RollRarity(probCfg); + item = forceTopUp && config.UpTarget != null && rarity >= config.TopRarity + ? PickGuaranteedItem(gachaCfg, config, preferUp: true) + : PickItem(allPoolItems, rarity); + trigger = forceTopUp && item != null && config.UpTarget != null && item.Rarity == config.UpTarget.Rarity; + } + + if (item != null && upSelectState.SelectedItem != null && item.Rarity >= config.TopRarity) + { + bool forceSelected = upSelectState.GuaranteedNext; + bool shouldSelect = forceSelected || Rng.Next(100) < 50; + if (shouldSelect) + { + var selectedItem = FindExactItem(allPoolItems, upSelectState.SelectedItem); + if (selectedItem != null && selectedItem.Rarity >= config.TopRarity) + item = selectedItem; + } + } + + if (item == null || item.GDPL.Count < 4) + { + tbTrigger.Add(false); + continue; + } + + var g = item.GDPL[0]; + var d = item.GDPL[1]; + var p = item.GDPL[2]; + var l = item.GDPL[3]; + + awards.Add([g, d, p, l]); + tbTrigger.Add(trigger); + + UpdatePityState(pityState, config, item); + UpdateUpSelectState(upSelectState, config, item); + + var itemType = (ItemTypeEnum)g; + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + { + var alreadyOwned = player.CharacterManager.GetCharacterGDPL(itemType, (int)d, (int)p) != null; + if (!alreadyOwned) + { + var charInfo = await player.CharacterManager.AddCharacter(itemType, d, p, sendPacket: false); + if (charInfo != null) + { + syncItems.Add(charInfo.ToProto()); + tbNew.Add(awards.Count); + } + } + break; + } + case ItemTypeEnum.TYPE_WEAPON: + { + var weaponInfo = await player.InventoryManager.AddWeaponItem(itemType, d, p, l, sendPacket: false); + if (weaponInfo != null) syncItems.Add(weaponInfo.ToProto()); + break; + } + case ItemTypeEnum.TYPE_SUPPORT: + { + var cardInfo = await player.InventoryManager.AddSupportCardItem(d, p, l, sendPacket: false); + if (cardInfo != null) syncItems.Add(cardInfo.ToProto()); + break; + } + } + } + + if (awards.Count == 0) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + SavePityState(player, gachaCfg, pityState, awards.Count, sync); + SaveUpSelectState(player, gachaCfg, upSelectState, sync); + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + sync.Items.AddRange(syncItems); + + var rsp = BuildResponse(req.NId, awards, tbNew, tbTrigger); + await CallGSRouter.SendScript(connection, "Gacha_Launch", rsp, sync); + } + + private static bool HasGuaranteedTenRarity(GachaRuntimeConfig config, List> awards) + { + if (awards.Count == 0) + return false; + + int windowStart = awards.Count >= 9 ? awards.Count - 9 : 0; + for (int i = windowStart; i < awards.Count; i++) + { + var award = awards[i]; + if (award.Count < 4) + continue; + + var template = FindPoolItemByGdpl(config.AllPoolItems, award); + if (template != null && template.Rarity >= config.TenGuaranteeRarity) + return true; + } + + return false; + } + + private static GachaPoolItem? FindPoolItemByGdpl(List pool, List gdpl) => + pool.FirstOrDefault(x => + x.GDPL.Count >= 4 && + x.GDPL[0] == gdpl[0] && + x.GDPL[1] == gdpl[1] && + x.GDPL[2] == gdpl[2] && + x.GDPL[3] == gdpl[3]); + + private static GachaPoolItem? FindExactItem(List pool, uint[] gdpl) => + pool.FirstOrDefault(x => + x.GDPL.Count >= 4 && + x.GDPL[0] == gdpl[0] && + x.GDPL[1] == gdpl[1] && + x.GDPL[2] == gdpl[2] && + x.GDPL[3] == gdpl[3]); + + private static GachaRuntimeConfig BuildRuntimeConfig(GachaExcel gachaCfg, List poolNames) + { + var allPoolItems = poolNames.SelectMany(name => GameData.GachaPoolData[name]).ToList(); + var protectPools = ParsePoolRarities(gachaCfg.ProtectNum); + var upTarget = ParseSinglePoolRarity(gachaCfg.UpNum); + var topRarity = new[] { upTarget?.Rarity ?? 0 }.Concat(protectPools.Select(x => x.Rarity)).Max(); + if (topRarity <= 0) + topRarity = allPoolItems.Count == 0 ? 0 : allPoolItems.Max(x => x.Rarity); + + return new GachaRuntimeConfig + { + AllPoolItems = allPoolItems, + ProtectThreshold = ParseThreshold(gachaCfg.ProtectNum), + ProtectPools = protectPools, + UpTarget = upTarget, + TopRarity = topRarity, + TenGuaranteeRarity = 4 + }; + } + + private static int ParseThreshold(JToken? token) + { + if (token is not JArray arr || arr.Count == 0) + return 0; + + return arr[0]?.Value() ?? 0; + } + + private static List ParsePoolRarities(JToken? token) + { + var result = new List(); + if (token is not JArray arr || arr.Count < 2 || arr[1] is not JArray entries) + return result; + + foreach (var entry in entries.OfType()) + { + if (entry.Count < 2) + continue; + + var poolName = entry[0]?.Value(); + var rarity = entry[1]?.Value() ?? 0; + if (string.IsNullOrWhiteSpace(poolName) || rarity <= 0) + continue; + + result.Add(new PoolRarityRef(poolName, rarity)); + } + + return result; + } + + private static PoolRarityRef? ParseSinglePoolRarity(JToken? token) + { + if (token is not JArray arr || arr.Count < 2 || arr[1] is not JArray entry || entry.Count < 2) + return null; + + var poolName = entry[0]?.Value(); + var rarity = entry[1]?.Value() ?? 0; + return string.IsNullOrWhiteSpace(poolName) || rarity <= 0 ? null : new PoolRarityRef(poolName, rarity); + } + + private static GachaPityState LoadPityState(PlayerInstance player, GachaExcel gachaCfg) + { + var baseSid = GetBaseSid(gachaCfg); + return new GachaPityState + { + ItemCount = (int)GetAttr(player, GachaGid, baseSid + SidAddTimeItem), + TenCount = (int)GetAttr(player, GachaGid, baseSid + SidAddTimeProb), + ProtectType = Math.Max(1, (int)GetAttr(player, GachaGid, baseSid + SidAddProtectType)), + PoolTotalTime = (int)GetAttr(player, GachaGid, baseSid + SidAddTotalTime) + }; + } + + private static void SavePityState(PlayerInstance player, GachaExcel gachaCfg, GachaPityState state, int drawCount, NtfSyncPlayer sync) + { + var baseSid = GetBaseSid(gachaCfg); + + SetAttr(player, sync, GachaGid, SidTotalTime, GetAttr(player, GachaGid, SidTotalTime) + (uint)drawCount); + SetAttr(player, sync, GachaGid, SidDailyTotalTime, GetAttr(player, GachaGid, SidDailyTotalTime) + (uint)drawCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddTimeItem, (uint)state.ItemCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddTimeProb, (uint)state.TenCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddProtectType, (uint)Math.Max(1, state.ProtectType)); + SetAttr(player, sync, GachaGid, baseSid + SidAddTotalTime, (uint)(state.PoolTotalTime + drawCount)); + } + + private static GachaUpSelectState LoadUpSelectState(PlayerInstance player, GachaExcel gachaCfg) + { + if (gachaCfg.UpSelect != 1) + return new GachaUpSelectState(); + + var raw = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GachaSgid && x.Sid == gachaCfg.ID)?.Val; + if (string.IsNullOrWhiteSpace(raw)) + return new GachaUpSelectState(); + + try + { + var state = JArray.Parse(raw); + uint[]? selected = null; + if (state.Count > UpSelectIndex && state[UpSelectIndex] is JArray selectedArray && selectedArray.Count >= 4) + { + selected = + [ + selectedArray[0]?.Value() ?? 0, + selectedArray[1]?.Value() ?? 0, + selectedArray[2]?.Value() ?? 0, + selectedArray[3]?.Value() ?? 0 + ]; + } + + return new GachaUpSelectState + { + SelectedItem = selected, + GuaranteedNext = state.Count > UpSelectGetFlagIndex && (state[UpSelectGetFlagIndex]?.Value() ?? 0) == 1, + RawState = state + }; + } + catch + { + return new GachaUpSelectState(); + } + } + + private static void SaveUpSelectState(PlayerInstance player, GachaExcel gachaCfg, GachaUpSelectState state, NtfSyncPlayer sync) + { + if (gachaCfg.UpSelect != 1 || state.RawState == null) + return; + + EnsureArraySize(state.RawState, 2); + state.RawState[UpSelectGetFlagIndex] = state.GuaranteedNext ? 1 : 0; + + var value = state.RawState.ToString(Newtonsoft.Json.Formatting.None); + player.SetStrAttr(GachaSgid, gachaCfg.ID, value); + sync.CustomStr[player.ToShiftedAttrKey(GachaSgid, gachaCfg.ID)] = value; + } + + private static uint GetBaseSid(GachaExcel gachaCfg) + { + if (gachaCfg.ProtectTag.HasValue) + return SidTimeInheritStart + (gachaCfg.ProtectTag.Value * Interval); + + return SidTimeNotInheritStart + (gachaCfg.ID * Interval); + } + + private static uint GetAttr(PlayerInstance player, uint gid, uint sid) => + player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + + private static void SetAttr(PlayerInstance player, NtfSyncPlayer sync, uint gid, uint sid, uint value) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr { Gid = gid, Sid = sid }; + player.Data.Attrs.Add(attr); + } + + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(gid, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(gid, sid)] = value; + } + + private static void UpdatePityState(GachaPityState state, GachaRuntimeConfig config, GachaPoolItem item) + { + if (item.Rarity >= config.TenGuaranteeRarity) + state.TenCount = 0; + else + state.TenCount++; + + if (item.Rarity >= config.TopRarity) + { + state.ItemCount = 0; + if (config.UpTarget != null) + state.ProtectType = IsFromPool(item, config.UpTarget) ? 1 : 2; + else + state.ProtectType = 1; + } + else + { + state.ItemCount++; + } + } + + private static void UpdateUpSelectState(GachaUpSelectState state, GachaRuntimeConfig config, GachaPoolItem item) + { + if (state.SelectedItem == null || item.Rarity < config.TopRarity) + return; + + state.GuaranteedNext = !MatchesGdpl(item, state.SelectedItem); + } + + private static bool MatchesGdpl(GachaPoolItem item, uint[] gdpl) => + item.GDPL.Count >= 4 && + item.GDPL[0] == gdpl[0] && + item.GDPL[1] == gdpl[1] && + item.GDPL[2] == gdpl[2] && + item.GDPL[3] == gdpl[3]; + + private static void EnsureArraySize(JArray state, int size) + { + while (state.Count < size) + state.Add(JValue.CreateNull()); + } + + private static bool IsFromPool(GachaPoolItem item, PoolRarityRef target) => + item.Rarity == target.Rarity && + GameData.GachaPoolData.TryGetValue(target.PoolName, out var pool) && + pool.Any(x => x.ID == item.ID); + + private static int RollRarity(GachaProbabilityExcel prob) + { + var weights = prob.Weights; + int total = weights.Sum(); + int roll = Rng.Next(total); + int cumulative = 0; + for (int i = 0; i < weights.Length; i++) + { + cumulative += weights[i]; + if (roll < cumulative) + return i + 1; + } + + return 3; + } + + private static GachaPoolItem? PickGuaranteedItem(GachaExcel gachaCfg, GachaRuntimeConfig config, bool preferUp) + { + if (preferUp && config.UpTarget != null) + { + var upItem = PickItemFromPool(config.UpTarget.PoolName, config.UpTarget.Rarity); + if (upItem != null) + return upItem; + } + + foreach (var poolRef in config.ProtectPools) + { + var item = PickItemFromPool(poolRef.PoolName, poolRef.Rarity); + if (item != null) + return item; + } + + return PickItem(config.AllPoolItems, config.TopRarity); + } + + private static GachaPoolItem? PickItemFromPool(string poolName, int rarity) + { + if (!GameData.GachaPoolData.TryGetValue(poolName, out var pool)) + return null; + + return PickItem(pool, rarity); + } + + private static GachaPoolItem? PickItem(List pool, int rarity) + { + var candidates = pool.Where(x => x.Rarity == rarity).ToList(); + if (candidates.Count == 0) + { + candidates = pool.Where(x => x.Rarity == rarity - 1).ToList(); + if (candidates.Count == 0) + return pool.FirstOrDefault(); + } + + int total = candidates.Sum(x => x.Weight); + if (total <= 0) + return candidates[Rng.Next(candidates.Count)]; + + int roll = Rng.Next(total); + int cumulative = 0; + foreach (var item in candidates) + { + cumulative += item.Weight; + if (roll < cumulative) + return item; + } + + return candidates.Last(); + } + + private static string BuildResponse(int nId, List> awards, List tbNew, List tbTrigger) + { + var sb = new StringBuilder(); + sb.Append("{\"nId\":"); + sb.Append(nId); + sb.Append(",\"tbAwards\":["); + for (int i = 0; i < awards.Count; i++) + { + if (i > 0) + sb.Append(','); + + sb.Append('['); + sb.Append(string.Join(',', awards[i])); + sb.Append(']'); + } + + sb.Append("],\"nBoxCount\":0,\"tbNew\":["); + sb.Append(string.Join(',', tbNew)); + sb.Append("],\"tbTrigger\":["); + sb.Append(string.Join(',', tbTrigger.Select(b => b ? "true" : "false"))); + sb.Append("]}"); + return sb.ToString(); + } +} + +internal sealed class GachaLaunchParam +{ + [JsonPropertyName("nId")] + public int NId { get; set; } + + [JsonPropertyName("bPickUp")] + public bool BPickUp { get; set; } + + [JsonPropertyName("nTime")] + public int NTime { get; set; } +} + +internal sealed class GachaPityState +{ + public int ItemCount { get; set; } + public int TenCount { get; set; } + public int ProtectType { get; set; } = 1; + public int PoolTotalTime { get; set; } +} + +internal sealed class GachaRuntimeConfig +{ + public List AllPoolItems { get; set; } = []; + public int ProtectThreshold { get; set; } + public List ProtectPools { get; set; } = []; + public PoolRarityRef? UpTarget { get; set; } + public int TopRarity { get; set; } + public int TenGuaranteeRarity { get; set; } +} + +internal sealed class GachaUpSelectState +{ + public uint[]? SelectedItem { get; set; } + public bool GuaranteedNext { get; set; } + public JArray? RawState { get; set; } = new(); +} + +internal sealed record PoolRarityRef(string PoolName, int Rarity); diff --git a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs new file mode 100644 index 0000000..eab74fa --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs @@ -0,0 +1,82 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Proto; +using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Gacha; + +[CallGSApi("Gacha_UpSelect")] +public class Gacha_UpSelect : ICallGSHandler +{ + private const uint GachaStrGid = 42; + private const int UpSelectIndex = 0; + private const int UpSelectGetFlagIndex = 1; + private const int UpPickPoolIndex = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + var player = connection.Player!; + if (req == null || req.NId == 0 || req.Gdpl == null || req.Gdpl.Count < 4) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.GachaData.TryGetValue((uint)req.NId, out var gachaCfg) || gachaCfg.UpSelect != 1) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var valid = (gachaCfg.Pool ?? []) + .Where(GameData.GachaPoolData.ContainsKey) + .SelectMany(name => GameData.GachaPoolData[name]) + .Any(item => + item.UPSelectTag == 1 && + item.GDPL.Count >= 4 && + item.GDPL[0] == req.Gdpl[0] && + item.GDPL[1] == req.Gdpl[1] && + item.GDPL[2] == req.Gdpl[2] && + item.GDPL[3] == req.Gdpl[3]); + + if (!valid) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var existing = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GachaStrGid && x.Sid == (uint)req.NId)?.Val; + var state = string.IsNullOrWhiteSpace(existing) ? new JArray() : JArray.Parse(existing); + + EnsureArraySize(state, 3); + state[UpSelectIndex] = new JArray(req.Gdpl); + state[UpSelectGetFlagIndex] = 0; + if (state[UpPickPoolIndex] == null) + state[UpPickPoolIndex] = 0; + + player.SetStrAttr(GachaStrGid, (uint)req.NId, state.ToString(Newtonsoft.Json.Formatting.None)); + DatabaseHelper.SaveDatabaseType(player.Data); + + var sync = new NtfSyncPlayer(); + sync.CustomStr[player.ToShiftedAttrKey(GachaStrGid, (uint)req.NId)] = state.ToString(Newtonsoft.Json.Formatting.None); + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{}", sync); + } + + private static void EnsureArraySize(JArray state, int size) + { + while (state.Count < size) + state.Add(JValue.CreateNull()); + } +} + +internal sealed class GachaUpSelectParam +{ + [JsonPropertyName("nId")] + public int NId { get; set; } + + [JsonPropertyName("gdpl")] + public List? Gdpl { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs new file mode 100644 index 0000000..790fd75 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs @@ -0,0 +1,127 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Girl; + +[CallGSApi("GirlCard_UpBySpecialBreak")] +public class GirlCard_UpBySpecialBreak : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.CardId == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var card = player.CharacterManager.GetCharacterByGUID((uint)req.CardId); + if (card == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cardTemplate = GameData.CardData.Values.FirstOrDefault(x => + GameResourceTemplateId.FromGdpl(x.Genre, x.Detail, x.Particular, x.Level) == card.TemplateId); + if (cardTemplate == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (cardTemplate.BreakMatID <= 10000 || + !GameData.SpecialBreakData.TryGetValue(cardTemplate.BreakMatID, out var specialBreakExcel)) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var nextBreak = card.Break + 1; + if (!specialBreakExcel.HasBreakLevel(nextBreak)) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.already_max_break\"}"); + return; + } + + var requestedMaterials = new Dictionary(); + foreach (var row in specialBreakExcel.GetItems(nextBreak)) + { + if (row.Count < 5) + continue; + + var templateId = GameResourceTemplateId.FromGdpl( + (uint)Math.Max(0, row[0]), + (uint)Math.Max(0, row[1]), + (uint)Math.Max(0, row[2]), + (uint)Math.Max(0, row[3])); + var count = (uint)Math.Max(0, row[4]); + if (templateId == 0 || count == 0) + continue; + + requestedMaterials[templateId] = requestedMaterials.GetValueOrDefault(templateId) + count; + } + + if (requestedMaterials.Count == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.not_material_for_break\"}"); + return; + } + + foreach (var (templateId, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (item == null || item.ItemCount < count) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.not_material_for_break\"}"); + return; + } + } + + var syncItems = new List(); + foreach (var (templateId, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.First(x => x.TemplateId == templateId); + item.ItemCount -= count; + + if (item.ItemCount == 0) + { + player.InventoryManager.InventoryData.Items.Remove(item.UniqueId); + syncItems.Add(BuildRemovedProto(item)); + } + else + { + syncItems.Add(item.ToProto()); + } + } + + card.Break = nextBreak; + syncItems.Add(card.ToProto()); + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var sync = new NtfSyncPlayer(); + sync.Items.AddRange(syncItems); + + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{}", sync); + } + + private static Item BuildRemovedProto(BaseGameItemInfo item) + { + var proto = item.ToProto(); + proto.Count = 0; + return proto; + } +} + +internal sealed class GirlCardUpBySpecialBreakParam +{ + [JsonPropertyName("nCardId")] + public int CardId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs b/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs new file mode 100644 index 0000000..e7b9398 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Guide; + +[CallGSApi("GuideLogic_WriteGuideLog")] +public class GuideLogic_WriteGuideLog : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + // Client writes guide progress log. Return empty success JSON to client. + // param: {nGuideId, ...} + await CallGSRouter.SendScript(connection, "GuideLogic_WriteGuideLog", "{}"); + } +} diff --git a/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs b/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs new file mode 100644 index 0000000..80b892f --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs @@ -0,0 +1,43 @@ +using MikuSB.Database; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Lineup; + +[CallGSApi("Lineups_Update")] +public class Lineups_Update : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize>(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "UpdateLineup", "{}"); + return; + } + + foreach (var lineup in req) + { + if (lineup == null) + continue; + + await connection.Player!.LineupManager.UpdateLineup( + lineup.Index, + lineup.Member1, + lineup.Member2, + lineup.Member3); + } + + DatabaseHelper.SaveDatabaseType(connection.Player!.LineupManager.LineupData); + await CallGSRouter.SendScript(connection, "UpdateLineup", "{}"); + } +} + +internal sealed class LineupUpdateBatchParam +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("member1")] public uint Member1 { get; set; } + [JsonPropertyName("member2")] public uint Member2 { get; set; } + [JsonPropertyName("member3")] public uint Member3 { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs b/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs new file mode 100644 index 0000000..e14d2ae --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs @@ -0,0 +1,59 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("Adjust_Record")] +public class Adjust_Record : ICallGSHandler +{ + private const uint GroupId = 107; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Type == 0) + { + await CallGSRouter.SendScript(connection, "Adjust_Record", "null"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var attr = GetOrCreateAttr(player, req.Type); + + if (attr.Val == 0) + { + attr.Val = 1; + sync.Custom[player.ToPackedAttrKey(GroupId, req.Type)] = 1; + sync.Custom[player.ToShiftedAttrKey(GroupId, req.Type)] = 1; + DatabaseHelper.SaveDatabaseType(player.Data); + } + + await CallGSRouter.SendScript(connection, "Adjust_Record", "null", sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } +} + +internal sealed class AdjustRecordParam +{ + [JsonPropertyName("nType")] + public uint Type { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs new file mode 100644 index 0000000..159ff02 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs @@ -0,0 +1,10 @@ +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("ExtendFightDynamicLog")] +public class ExtendFightDynamicLog : ICallGSHandler +{ + public Task Handle(Connection connection, string param, ushort seqNo) + { + return Task.CompletedTask; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs new file mode 100644 index 0000000..1a5acf8 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs @@ -0,0 +1,10 @@ +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("ExtendFightLog")] +public class ExtendFightLog : ICallGSHandler +{ + public Task Handle(Connection connection, string param, ushort seqNo) + { + return Task.CompletedTask; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs new file mode 100644 index 0000000..bac30d9 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs @@ -0,0 +1,71 @@ +using MikuSB.Data; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Rogue3D; + +// Enters the Rogue3D season level. Returns a random seed used by the client for map generation. +// Persists SeasonGameplayId (sid=1006) and SeasonEnterFlag (sid=1008) as player attributes (GroupId=124). +// param: {"nDiffId", "nTeamID", "tbTeam", "tbBuffList", "tbLog"} +// Response: {"nSeed": int} on success, {"sErr": "key"} on failure +[CallGSApi("Rogue3D_EnterSeasonLevel")] +public class Rogue3D_EnterSeasonLevel : ICallGSHandler +{ + private const uint GroupId = 124; + private const uint SeasonGameplayIdSid = 1006; + private const uint SeasonEnterFlagSid = 1008; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", "{\"nSeed\":0}"); + return; + } + + if (!GameData.Rogue3DDifficultData.TryGetValue(req.DiffId, out var cfg) || cfg.GameplayGroup.Count == 0) + { + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", "{\"sErr\":\"rogue3.massage_gameProcessError\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + SetAttr(player, SeasonGameplayIdSid, cfg.GameplayGroup[0], sync); + SetAttr(player, SeasonEnterFlagSid, 1, sync); + + var seed = Random.Next(1, 1_000_000_000); + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", $"{{\"nSeed\":{seed}}}", sync); + } + + private static void SetAttr(PlayerInstance player, uint sid, uint val, NtfSyncPlayer sync) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr { Gid = GroupId, Sid = sid }; + player.Data.Attrs.Add(attr); + } + + if (attr.Val == val) + { + return; + } + + attr.Val = val; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = val; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = val; + } +} + +internal sealed class EnterSeasonLevelParam +{ + [JsonPropertyName("nDiffId")] + public uint DiffId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs new file mode 100644 index 0000000..26be649 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs @@ -0,0 +1,47 @@ +using MikuSB.Database.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Rogue3D; + +// Selects the Rogue3D season talent and persists it as player attribute (GroupId=124, TalentId=1007). +// param: {"nTalentId": int} +// Response: {} on success, {"sErr": "key"} on failure +[CallGSApi("Rogue3D_SelectSeasonTalent")] +public class Rogue3D_SelectSeasonTalent : ICallGSHandler +{ + private const uint GroupId = 124; + private const uint SeasonTalentIdSid = 1007; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "Rogue3D_SelectSeasonTalent", "{}"); + return; + } + + var player = connection.Player!; + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == SeasonTalentIdSid); + if (attr == null) + { + attr = new PlayerAttr { Gid = GroupId, Sid = SeasonTalentIdSid }; + player.Data.Attrs.Add(attr); + } + attr.Val = req.TalentId; + + var sync = new NtfSyncPlayer(); + sync.Custom[player.ToPackedAttrKey(GroupId, SeasonTalentIdSid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(GroupId, SeasonTalentIdSid)] = attr.Val; + + await CallGSRouter.SendScript(connection, "Rogue3D_SelectSeasonTalent", "{}", sync); + } +} + +internal sealed class SelectSeasonTalentParam +{ + [JsonPropertyName("nTalentId")] + public uint TalentId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs new file mode 100644 index 0000000..d3c091a --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs @@ -0,0 +1,451 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("IBLogic_BuyGoods")] +public class IBLogic_BuyGoods : ICallGSHandler +{ + private const uint BuyGroupId = 26; + private const uint RedGroupId = 113; + private const uint CashGroupId = 1; + private const uint BattlePassGroupId = 25; + private const uint BattlePassCurIdSid = 1; + private const uint BattlePassStatusSid = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + var player = connection.Player!; + if (req?.Type == 3 && req.GoodsId > 0 && req.Count > 0) + { + await HandleBattlePassPurchase(connection, player, req); + return; + } + + if (req == null || + req.GoodsId == 0 || + req.Count == 0 || + !GameData.IbGoodsData.TryGetValue(req.GoodsId, out var goods)) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (goods.LimitTimes > 0) + { + var buyAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + if (buyAttr.Val >= goods.LimitTimes) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"tip.Mall_Limit_Buy\"}"); + return; + } + } + + var rewardItems = BuildRewardItems(goods, req); + if (rewardItems.Count == 0) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + foreach (var reward in rewardItems) + await GrantRewardAsync(player, sync, reward); + + var buyCountAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + buyCountAttr.Val += req.Count; + SyncAttr(player, sync, buyCountAttr); + + var redAttr = GetOrCreateAttr(player, RedGroupId, req.GoodsId); + if (redAttr.Val == 0) + { + redAttr.Val = 1; + SyncAttr(player, sync, redAttr); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var responseGoods = new JsonArray(); + foreach (var reward in rewardItems) + { + var row = new JsonArray(); + foreach (var value in reward) + row.Add((int)value); + responseGoods.Add(row); + } + + var rsp = new JsonObject + { + ["nGoodsId"] = (int)req.GoodsId, + ["tbGoods"] = responseGoods + }; + + var productId = goods.GetProductId(); + if (!string.IsNullOrWhiteSpace(productId)) + rsp["sProductId"] = productId; + + var cost = req.Index == 2 ? goods.Cost2 : goods.Cost; + if (cost.Count >= 2) + rsp["nTotalPrice"] = (int)cost[1]; + + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", rsp.ToJsonString(), sync); + } + + private static async Task HandleBattlePassPurchase(Connection connection, PlayerInstance player, IbBuyGoodsParam req) + { + var sync = new NtfSyncPlayer(); + var battlePassId = ResolveCurrentBattlePassId(); + if (battlePassId > 0) + { + var curIdAttr = GetOrCreateAttr(player, BattlePassGroupId, BattlePassCurIdSid); + curIdAttr.Val = battlePassId; + SyncAttr(player, sync, curIdAttr); + } + + var statusAttr = GetOrCreateAttr(player, BattlePassGroupId, BattlePassStatusSid); + if (statusAttr.Val < 2) + { + statusAttr.Val = 2; + SyncAttr(player, sync, statusAttr); + } + + var buyCountAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + buyCountAttr.Val += req.Count; + SyncAttr(player, sync, buyCountAttr); + + var redAttr = GetOrCreateAttr(player, RedGroupId, req.GoodsId); + if (redAttr.Val == 0) + { + redAttr.Val = 1; + SyncAttr(player, sync, redAttr); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + + var rsp = new JsonObject + { + ["nGoodsId"] = (int)req.GoodsId, + ["tbGoods"] = new JsonArray() + }; + + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", rsp.ToJsonString(), sync); + } + + private static List> BuildRewardItems(IbGoodsExcel goods, IbBuyGoodsParam req) + { + var rewards = new List>(); + + if (goods.Item.Count >= 4) + rewards.Add(WithCount(goods.Item, req.Count)); + + if (req.SelectItem1?.Count >= 4) + rewards.Add(WithCount(req.SelectItem1, req.Count)); + + if (req.SelectItem2?.Count >= 4) + rewards.Add(WithCount(req.SelectItem2, req.Count)); + + return rewards; + } + + private static List WithCount(IReadOnlyList item, uint buyCount) + { + var reward = item.Take(5).ToList(); + while (reward.Count < 5) + reward.Add(1); + + reward[4] = Math.Max(1u, reward[4]) * Math.Max(1u, buyCount); + return reward; + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + if (reward.Count < 5) + return; + + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (!GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + break; + + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + if (!TryGrantCashBox(player, sync, detail, particular, level, count)) + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static bool TryGrantCashBox(PlayerInstance player, NtfSyncPlayer sync, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl((uint)ItemTypeEnum.TYPE_USEABLE, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return false; + + uint moneyType = otherItem.LuaType switch + { + "money_box" => 1, + "gold_box" => 2, + "silver_box" => 3, + "vigor_box" => 4, + _ => 0 + }; + + if (moneyType == 0 || otherItem.Param1 == 0) + return false; + + var amount = checked(otherItem.Param1 * count); + var sid = moneyType * 2 + 1; + var attr = GetOrCreateAttr(player, CashGroupId, sid); + attr.Val += amount; + SyncAttr(player, sync, attr); + if (moneyType == 1) + { + foreach (var (key, value) in player.BuildMoneySync()) + sync.Money[key] = value; + } + return true; + } + + private static uint ResolveCurrentBattlePassId() + { + var now = DateTime.Now; + var parsed = GameData.BattlePassTimeData.Values + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config.Id; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config.Id ?? 0; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint gid, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class IbBuyGoodsParam +{ + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nGoodsId")] + public uint GoodsId { get; set; } + + [JsonPropertyName("nCount")] + public uint Count { get; set; } + + [JsonPropertyName("nIndex")] + public int Index { get; set; } + + [JsonPropertyName("tbSelectItem1")] + public List? SelectItem1 { get; set; } + + [JsonPropertyName("tbSelectItem2")] + public List? SelectItem2 { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs new file mode 100644 index 0000000..8c2bea3 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs @@ -0,0 +1,71 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("IBLogic_GoodsRedDot")] +public class IBLogic_GoodsRedDot : ICallGSHandler +{ + private const uint RedGroupId = 113; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req?.GoodsIds == null || req.GoodsIds.Count == 0) + { + await CallGSRouter.SendScript(connection, "IBLogic_GoodsRedDot", "null"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var changed = false; + + foreach (var goodsId in req.GoodsIds.Where(x => x > 0).Distinct()) + { + var attr = GetOrCreateAttr(player, RedGroupId, goodsId); + if (attr.Val > 0) + continue; + + attr.Val = 1; + SyncAttr(player, sync, attr); + changed = true; + } + + if (changed) + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "IBLogic_GoodsRedDot", "null", sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint gid, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class IbGoodsRedDotParam +{ + [JsonPropertyName("tbList")] + public List GoodsIds { get; set; } = []; +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs b/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs new file mode 100644 index 0000000..ee57e57 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("ShopLogic_GetGoodsList")] +public class ShopLogic_GetGoodsList : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = string.IsNullOrEmpty(param) + ? new ShopLogicGetGoodsListParam { ShopId = 0 } + : JsonSerializer.Deserialize(param); + var shopId = req?.ShopId ?? 0; + var goodsList = LoadShopGoods(shopId); + + var response = JsonSerializer.Serialize(new + { + nShopId = shopId, + tbGoodsList = goodsList, + GoodsList = goodsList + }); + + await CallGSRouter.SendScript(connection, "ShopLogic_GetGoodsList", response); + } + + private static JsonElement[] LoadShopGoods(int shopId) + { + if (shopId <= 0) + return []; + + var path = Path.Combine(AppContext.BaseDirectory, "Resources", "shop", "goods.json"); + if (!File.Exists(path)) + return []; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement + .EnumerateArray() + .Where(row => row.TryGetProperty("ShopId", out var shopIdProp) && GetIntValue(shopIdProp) == shopId) + .Select(row => row.Clone()) + .ToArray(); + } + + private static int GetIntValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Number => element.GetInt32(), + JsonValueKind.String => int.TryParse(element.GetString(), out var value) ? value : 0, + _ => 0 + }; + } +} + +internal sealed class ShopLogicGetGoodsListParam +{ + [JsonPropertyName("nShopId")] + public int ShopId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs new file mode 100644 index 0000000..a7e2e9c --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs @@ -0,0 +1,118 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_CheckCycleLevel")] +public class ClimbTowerLogic_CheckCycleLevel : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint TimeSubId = 1; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var current = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (current == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_CheckCycleLevel", "{}"); + return; + } + + var currentTimeId = GetAttr(player.Data, TowerGroupId, TimeSubId); + var sync = new NtfSyncPlayer(); + if (currentTimeId != current.ID) + { + ResetTowerAttrs(player, sync); + SetAttr(player.Data, TowerGroupId, TimeSubId, current.ID, sync, player); + DatabaseHelper.SaveDatabaseType(player.Data); + } + + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_CheckCycleLevel", $$"""{"timeID":{{current.ID}}}""", sync); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static uint GetAttr(PlayerGameData data, uint gid, uint sid) + { + return data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + + private static void ResetTowerAttrs(PlayerInstance player, NtfSyncPlayer sync) + { + var towerAttrs = player.Data.Attrs + .Where(x => x.Gid == TowerGroupId) + .ToList(); + + foreach (var attr in towerAttrs) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = 0; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = 0; + } + + player.Data.Attrs.RemoveAll(x => x.Gid == TowerGroupId); + } + + private static void SetAttr(PlayerGameData data, uint gid, uint sid, uint value, NtfSyncPlayer sync, PlayerInstance player) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + } + + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(gid, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(gid, sid)] = value; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs new file mode 100644 index 0000000..eb8372c --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs @@ -0,0 +1,449 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_GetReward")] +public class ClimbTowerLogic_GetReward : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint RewardStateSidBase = 100; + private const uint TowerLevelStateSidBase = 10000; + private const uint LaunchPassGroupId = 22; + private const uint AdvancedDiffSid = 4; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Layer <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!TryResolveLayer(cycle, req.Layer, player.Data, out var towerIds, out var diff)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.ClimbTowerAwardData.TryGetValue((uint)req.Layer, out var diffMap) || + !diffMap.TryGetValue(diff, out var rewardCfg)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var groups = ResolveRequestedGroups(req.Group); + if (groups.Count == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var claimableGroups = groups + .Where(group => CanClaimGroup(player.Data, rewardCfg, towerIds, req.Layer, group)) + .Distinct() + .ToList(); + + if (claimableGroups.Count == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + var rewardStateAttr = GetOrCreateAttr(player.Data, TowerGroupId, RewardStateSidBase + (uint)req.Layer); + var responseRewards = new JsonArray(); + + foreach (var group in claimableGroups) + { + rewardStateAttr.Val |= 1u << GetFlagBitOffset(group); + + foreach (var reward in rewardCfg.GetRewards(group)) + { + if (reward.Count < 5) + continue; + + await GrantRewardAsync(player, sync, reward); + responseRewards.Add(new JsonArray( + (int)reward[0], + (int)reward[1], + (int)reward[2], + (int)reward[3], + (int)reward[4])); + } + } + + SyncAttr(sync, player, rewardStateAttr); + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var rsp = new JsonObject + { + ["tbRewards"] = responseRewards + }; + + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", rsp.ToJsonString(), sync); + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static bool CanClaimGroup( + PlayerGameData data, + ClimbTowerAwardExcel rewardCfg, + IReadOnlyList towerIds, + int layer, + int group) + { + if (group is < 0 or > 3 || IsRewardClaimed(data, layer, group)) + return false; + + if (group == 0) + return IsLayerPass(data, towerIds); + + var requiredStar = rewardCfg.GetStarCount(group); + return requiredStar > 0 && GetLayerStar(data, towerIds) >= requiredStar; + } + + private static bool IsLayerPass(PlayerGameData data, IReadOnlyList towerIds) + { + foreach (var towerId in towerIds) + { + if (!GameData.ClimbTowerLevelOrderData.TryGetValue(towerId, out var orderCfg)) + return false; + + var passAttr = data.Attrs.FirstOrDefault(x => x.Gid == LaunchPassGroupId && x.Sid == orderCfg.LevelID); + if (passAttr == null || passAttr.Val == 0) + return false; + } + + return true; + } + + private static int GetLayerStar(PlayerGameData data, IReadOnlyList towerIds) + { + var total = 0; + foreach (var towerId in towerIds) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == TowerLevelStateSidBase + towerId); + var value = attr?.Val ?? 0; + for (var i = 0; i < 9; i++) + { + if (((value >> i) & 1u) != 0) + total++; + } + } + + return total; + } + + private static bool IsRewardClaimed(PlayerGameData data, int layer, int group) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == RewardStateSidBase + (uint)layer); + if (attr == null) + return false; + + var offset = GetFlagBitOffset(group); + return ((attr.Val >> offset) & 0xFu) > 0; + } + + private static int GetFlagBitOffset(int group) => group switch + { + 0 => 0, + 1 => 4, + 2 => 8, + 3 => 12, + _ => 0 + }; + + private static List ResolveRequestedGroups(int? group) + { + if (!group.HasValue) + return [0, 1, 2, 3]; + + return group.Value is >= 0 and <= 3 ? [group.Value] : []; + } + + private static bool TryResolveLayer( + ClimbTowerTimeExcel cycle, + int layer, + PlayerGameData data, + out IReadOnlyList towerIds, + out int diff) + { + var basicGroups = cycle.GetLevelGroups(1); + if (layer <= basicGroups.Count) + { + towerIds = basicGroups[layer - 1]; + diff = 1; + return towerIds.Count > 0; + } + + var advancedIndex = layer - basicGroups.Count; + var advancedGroups = cycle.GetLevelGroups(2); + if (advancedIndex <= 0 || advancedIndex > advancedGroups.Count) + { + towerIds = []; + diff = 0; + return false; + } + + var diffAttr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == AdvancedDiffSid); + diff = (int)(diffAttr?.Val ?? 0); + towerIds = advancedGroups[advancedIndex - 1]; + return diff > 0 && towerIds.Count > 0; + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class ClimbTowerGetRewardParam +{ + [JsonPropertyName("nType")] + public int? Type { get; set; } + + [JsonPropertyName("nLayer")] + public int Layer { get; set; } + + [JsonPropertyName("nGroup")] + public int? Group { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs new file mode 100644 index 0000000..fba93a1 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs @@ -0,0 +1,219 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_RecordProgres")] +public class ClimbTowerLogic_RecordProgres : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint BasicProgressSid = 2; + private const uint AdvancedProgressSid = 3; + private const uint LevelStateSidBase = 10000; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.Area <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var towerType = ResolveTowerType(cycle, (uint)req.LevelId); + if (towerType == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + + var levelStateSid = LevelStateSidBase + (uint)req.LevelId; + var levelState = GetOrCreateAttr(player.Data, TowerGroupId, levelStateSid); + levelState.Val = MergeAreaStars(levelState.Val, req.Area, req.StarMask); + SyncAttr(sync, player, levelState); + + var progressSid = towerType == 1 ? BasicProgressSid : AdvancedProgressSid; + var progressAttr = GetOrCreateAttr(player.Data, TowerGroupId, progressSid); + progressAttr.Val = req.Area >= 3 ? 0u : PackProgress((uint)req.LevelId, (uint)(req.Area + 1)); + SyncAttr(sync, player, progressAttr); + + if (req.RoleHP.Count > 0 || req.TeamEnergy.HasValue) + { + SaveRoleState(player, sync, towerType, req.RoleHP, req.TeamEnergy.GetValueOrDefault()); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{}", sync); + } + + private static void SaveRoleState( + PlayerInstance player, + NtfSyncPlayer sync, + int towerType, + List> roleHp, + int teamEnergy) + { + var slotStart = towerType == 2 ? 4u : 1u; + + for (var slot = slotStart; slot < slotStart + 3; slot++) + { + var templateAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10); + var hpAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10 + 1); + templateAttr.Val = 0; + hpAttr.Val = 0; + SyncAttr(sync, player, templateAttr); + SyncAttr(sync, player, hpAttr); + } + + for (var i = 0; i < Math.Min(roleHp.Count, 3); i++) + { + var row = roleHp[i]; + if (row == null || row.Count < 2) + continue; + + var slot = slotStart + (uint)i; + var templateAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10); + var hpAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10 + 1); + templateAttr.Val = (uint)Math.Max(0, row[0]); + hpAttr.Val = (uint)Math.Max(0, row[1]); + SyncAttr(sync, player, templateAttr); + SyncAttr(sync, player, hpAttr); + } + + var energyAttr = GetOrCreateAttr(player.Data, TowerGroupId, slotStart * 10 + 2); + energyAttr.Val = (uint)Math.Max(0, teamEnergy); + SyncAttr(sync, player, energyAttr); + } + + private static uint MergeAreaStars(uint currentValue, int area, int starMask) + { + var areaIndex = Math.Clamp(area, 1, 3) - 1; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + var bitIndex = areaIndex * 3 + i; + result |= 1u << bitIndex; + } + + return result; + } + + private static uint PackProgress(uint levelId, uint area) => (area << 24) | (levelId & 0x00FF_FFFF); + + private static int ResolveTowerType(ClimbTowerTimeExcel cycle, uint levelId) + { + if (ContainsLevel(cycle.GetLevelGroups(1), levelId)) + return 1; + + if (ContainsLevel(cycle.GetLevelGroups(2), levelId)) + return 2; + + return 0; + } + + private static bool ContainsLevel(IEnumerable> groups, uint levelId) + { + return groups.Any(group => group.Any(id => id == levelId)); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class ClimbTowerRecordProgressParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nArea")] + public int Area { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } + + [JsonPropertyName("tbRoleHP")] + public List> RoleHP { get; set; } = []; + + [JsonPropertyName("nTeamEnergy")] + public int? TeamEnergy { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs new file mode 100644 index 0000000..ec96141 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs @@ -0,0 +1,76 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_SetLevelDiff")] +public class ClimbTowerLogic_SetLevelDiff : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint DiffSid = 4; + private const uint HisDiffSid = 5; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Diff <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.ClimbTowerDiffData.ContainsKey((uint)req.Diff)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var hisDiff = GetAttrValue(player.Data, TowerGroupId, HisDiffSid); + if (req.Diff > hisDiff + 1) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var diffAttr = GetOrCreateAttr(player.Data, TowerGroupId, DiffSid); + diffAttr.Val = (uint)req.Diff; + + var sync = new NtfSyncPlayer(); + sync.Custom[player.ToPackedAttrKey(diffAttr.Gid, diffAttr.Sid)] = diffAttr.Val; + sync.Custom[player.ToShiftedAttrKey(diffAttr.Gid, diffAttr.Sid)] = diffAttr.Val; + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{}", sync); + } + + private static uint GetAttrValue(PlayerGameData data, uint gid, uint sid) + { + return data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } +} + +internal sealed class ClimbTowerSetLevelDiffParam +{ + [JsonPropertyName("nDiff")] + public int Diff { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs new file mode 100644 index 0000000..1909c4d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs @@ -0,0 +1,39 @@ +using MikuSB.Data; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerEventChapter_EnterLevel")] +public class TowerEventChapter_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.TowerEventLevelData.ContainsKey((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", rsp); + } +} + +internal sealed class TowerEventEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs new file mode 100644 index 0000000..bb0de54 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs @@ -0,0 +1,82 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerEventChapter_LevelSettlement")] +public class TowerEventChapter_LevelSettlement : ICallGSHandler +{ + private const uint LevelStateGroupId = 21; + private const uint LaunchPassGroupId = 22; + private const uint PassedFlagMask = (1u << 8) | 0b111u; + private static readonly Logger Logger = new("TowerEvent"); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "TowerEventChapter_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId == 0 || req.ChapterId == 0) + { + Logger.Error($"Invalid tower event settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + + var levelStateAttr = GetOrCreateAttr(player.Data, LevelStateGroupId, (uint)req.LevelId); + levelStateAttr.Val |= PassedFlagMask; + SyncAttr(sync, player, levelStateAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"TowerEvent settlement saved. uid={player.Uid} chapterId={req.ChapterId} levelId={req.LevelId} " + + $"levelStateVal={levelStateAttr.Val} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class TowerEventSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nChapterID")] + public int ChapterId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs new file mode 100644 index 0000000..4df6a7b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs @@ -0,0 +1,39 @@ +using MikuSB.Data; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerLevel_EnterLevel")] +public class TowerLevel_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.TowerLevelData.ContainsKey((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", rsp); + } +} + +internal sealed class TowerLevelEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs new file mode 100644 index 0000000..0fdb5b6 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs @@ -0,0 +1,178 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerLevel_LevelSettlement")] +public class TowerLevel_LevelSettlement : ICallGSHandler +{ + private static readonly Logger Logger = new("Tower"); + private const uint TowerGroupId = 3; + private const uint LaunchPassGroupId = 22; + private const uint BasicProgressSid = 2; + private const uint AdvancedProgressSid = 3; + private const uint LevelStateSidBase = 10000; + private const int FinalArea = 3; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "TowerLevel_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.TowerId == 0 || req.LevelId == 0) + { + Logger.Error($"Invalid tower settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var towerType = ResolveTowerType(cycle, (uint)req.TowerId); + if (towerType == 0) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var sync = new NtfSyncPlayer(); + var levelStateSid = LevelStateSidBase + (uint)req.TowerId; + var levelState = GetOrCreateAttr(player.Data, TowerGroupId, levelStateSid); + levelState.Val = MergeAreaStars(levelState.Val, FinalArea, req.StarMask); + SyncAttr(sync, player, levelState); + + var progressSid = towerType == 1 ? BasicProgressSid : AdvancedProgressSid; + var progressAttr = GetOrCreateAttr(player.Data, TowerGroupId, progressSid); + progressAttr.Val = 0; + SyncAttr(sync, player, progressAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"Tower settlement saved. uid={player.Uid} towerId={req.TowerId} levelId={req.LevelId} starMask={req.StarMask} " + + $"towerStateSid={levelStateSid} towerStateVal={levelState.Val} progressSid={progressSid} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static uint MergeAreaStars(uint currentValue, int area, int starMask) + { + var areaIndex = Math.Clamp(area, 1, 3) - 1; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + var bitIndex = areaIndex * 3 + i; + result |= 1u << bitIndex; + } + + return result; + } + + private static int ResolveTowerType(ClimbTowerTimeExcel cycle, uint levelId) + { + if (ContainsLevel(cycle.GetLevelGroups(1), levelId)) + return 1; + + if (ContainsLevel(cycle.GetLevelGroups(2), levelId)) + return 2; + + return 0; + } + + private static bool ContainsLevel(IEnumerable> groups, uint levelId) + { + return groups.Any(group => group.Any(id => id == levelId)); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(MikuSB.Proto.NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class TowerLevelSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTowerID")] + public int TowerId { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs new file mode 100644 index 0000000..2c87845 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs @@ -0,0 +1,134 @@ +using MikuSB.Data.Excel; +using MikuSB.Util; +using Newtonsoft.Json.Linq; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +internal static class VirCaptureCaptureRewardResolver +{ + private static readonly Lock CacheLock = new(); + private static readonly Dictionary> RegionCache = []; + private static readonly Dictionary>> BossCache = []; + + public static List? ResolveGdpl(VirCaptureCaptureRegionExcel captureRegion, uint regionId) + { + if (string.IsNullOrWhiteSpace(captureRegion.LevelRegionName)) + return null; + + var regionMap = GetOrLoadRegionMap(captureRegion.LevelRegionName); + if (!regionMap.TryGetValue(regionId, out var regionReward)) + return null; + + if (regionReward.PalType == 2) + return GetOrLoadBossMap(captureRegion.LevelRegionName).GetValueOrDefault(regionId); + + return regionReward.Rewards1; + } + + private static Dictionary GetOrLoadRegionMap(string mapName) + { + lock (CacheLock) + { + if (RegionCache.TryGetValue(mapName, out var cached)) + return cached; + + var loaded = new Dictionary(); + var path = Path.Combine( + ConfigManager.Config.Path.ResourcePath, + "dlc", + "vircapture", + mapName, + "region_info.json"); + + if (File.Exists(path)) + { + var array = JArray.Parse(File.ReadAllText(path)); + foreach (var token in array) + { + var id = ReadUInt(token["Id"]); + if (id == 0) + continue; + + loaded[id] = new VirCaptureLevelRegionReward + { + PalType = ReadInt(token["PalType"]), + Rewards1 = token["Rewards1"]?.ToObject>() ?? [] + }; + } + } + + RegionCache[mapName] = loaded; + return loaded; + } + } + + private static Dictionary> GetOrLoadBossMap(string mapName) + { + lock (CacheLock) + { + if (BossCache.TryGetValue(mapName, out var cached)) + return cached; + + var loaded = new Dictionary>(); + var path = Path.Combine( + ConfigManager.Config.Path.ResourcePath, + "dlc", + "vircapture", + mapName, + "boss.json"); + + if (File.Exists(path)) + { + var array = JArray.Parse(File.ReadAllText(path)); + foreach (var token in array) + { + var regionId = ReadUInt(token["RegionId"]); + var boss = token["Boss"]?.ToObject>(); + if (regionId == 0 || boss == null || boss.Count < 4) + continue; + + loaded.TryAdd(regionId, boss); + } + } + + BossCache[mapName] = loaded; + return loaded; + } + } + + private sealed class VirCaptureLevelRegionReward + { + public int PalType { get; init; } + public List Rewards1 { get; init; } = []; + } + + private static uint ReadUInt(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => Math.Max(0u, (uint)token.Value()), + JTokenType.String when uint.TryParse(token.Value(), out var value) => value, + JTokenType.String => 0, + _ => 0 + }; + } + + private static int ReadInt(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (int)token.Value(), + JTokenType.String when int.TryParse(token.Value(), out var value) => value, + JTokenType.String => 0, + _ => 0 + }; + } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs new file mode 100644 index 0000000..98ea278 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs @@ -0,0 +1,40 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_ChangeFlag")] +public class VirCaptureLevel_ChangeFlag : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_ChangeFlag", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, req.Clean ? 0u : 1u, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + var rsp = $"{{\"nLevelID\":{req.LevelId},\"nRegionId\":{req.RegionId},\"bClean\":{req.Clean.ToString().ToLowerInvariant()}}}"; + await CallGSRouter.SendScript(connection, "VirCaptureLevel_ChangeFlag", rsp, sync); + } +} + +internal sealed class VirCaptureChangeFlagParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } + + [JsonPropertyName("bClean")] + public bool Clean { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs new file mode 100644 index 0000000..f5933ef --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs @@ -0,0 +1,171 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_EnterLevel")] +public class VirCaptureLevel_EnterLevel : ICallGSHandler +{ + private const uint GroupId = 128; + private const uint MapDataStart = 10000; + private const uint MaxMapCount = 3; + private const uint MaxMapDataLen = 3000; + private const uint OffMapId = 1; + private const uint OffDayNight = 7; + private const uint OffMapLevel = 8; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var now = DateTime.Now; + var act = ResolveCurrent(GameData.VirCaptureTimeData.Values, now); + if (act == null || !act.CaptureRegionId.Contains((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"ui.TxtNotOpen\"}"); + return; + } + + if (!GameData.VirCaptureCaptureRegionData.TryGetValue((uint)req.LevelId, out var region)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var regionStart = ParseConfigTime(region.StartTime); + var regionEnd = ParseConfigTime(region.EndTime); + if (!regionStart.HasValue || !regionEnd.HasValue || now < regionStart.Value || now >= regionEnd.Value) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"ui.TxtNotOpen\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + EnsureMapState(player, (uint)req.LevelId, sync); + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", rsp, sync); + } + + private static void EnsureMapState(PlayerInstance player, uint levelId, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureMapAttr(player, slotStart + OffMapId, levelId, sync); + EnsureMapAttr(player, slotStart + OffDayNight, 1, sync); + EnsureMapAttr(player, slotStart + OffMapLevel, 1, sync); + } + + private static uint FindOrAllocateMapSlot(PlayerInstance player, uint levelId) + { + uint? emptySlot = null; + for (uint i = 0; i < MaxMapCount; i++) + { + var slotStart = MapDataStart + (i * MaxMapDataLen); + var mapIdAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == slotStart + OffMapId); + if (mapIdAttr?.Val == levelId) + return slotStart; + + if (emptySlot == null && (mapIdAttr == null || mapIdAttr.Val == 0)) + emptySlot = slotStart; + } + + return emptySlot ?? 0; + } + + private static void EnsureMapAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid, + Val = minValue + }; + player.Data.Attrs.Add(attr); + SyncAttr(player, sync, sid, minValue); + return; + } + + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + + private static VirCaptureTimeExcel? ResolveCurrent(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null && latestStarted.End > latestStarted.Start) + return latestStarted.Config; + + return null; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class VirCaptureEnterLevelParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs new file mode 100644 index 0000000..c7854dc --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs @@ -0,0 +1,222 @@ +using MikuSB.Database; +using MikuSB.Data; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SaveCapture")] +public class VirCaptureLevel_SaveCapture : ICallGSHandler +{ + private const uint VirCaptureGroupId = 128; + private const uint CurExpSid = 2; + private const uint CurLevelSid = 3; + private const uint BagNumSid = 5; + private const uint DailyExpSid = 8; + private const uint ColorMaxStartSid = 11; + private const uint RikiGroupId = 135; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, 2u, sync); + + if (!GameData.VirCaptureCaptureRegionData.TryGetValue((uint)req.LevelId, out var captureRegion)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rewardGdpl = VirCaptureCaptureRewardResolver.ResolveGdpl(captureRegion, (uint)req.RegionId); + if (rewardGdpl == null || rewardGdpl.Count < 4 || rewardGdpl[0] != (uint)ItemTypeEnum.TYPE_MONSTER_CARD) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}", sync); + return; + } + + var grantedItem = await player.InventoryManager.AddMonsterCardItem( + rewardGdpl[1], + rewardGdpl[2], + rewardGdpl[3], + sendPacket: false); + if (grantedItem == null) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}", sync); + return; + } + + sync.Items.Add(grantedItem.ToProto()); + SyncVirCaptureCounters(player, grantedItem.TemplateId, sync); + ApplyCaptureExp(player, grantedItem.TemplateId, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + + var response = new JsonObject + { + ["nLevelID"] = req.LevelId, + ["nRegionId"] = req.RegionId, + ["nAddItemId"] = grantedItem.UniqueId, + ["tbGDPL"] = new JsonArray(rewardGdpl.Select(x => JsonValue.Create((int)x)).ToArray()) + }; + + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", response.ToJsonString(), sync); + } + + private static void SyncVirCaptureCounters(MikuSB.GameServer.Game.Player.PlayerInstance player, ulong templateId, NtfSyncPlayer sync) + { + var bagCount = (uint)player.InventoryManager.InventoryData.Items.Values.Count(x => x.ItemType == ItemTypeEnum.TYPE_MONSTER_CARD); + VirCaptureStateHelper.SetUnsignedAttr(player, BagNumSid, bagCount, sync); + + if (!GameData.MonsterCardData.TryGetValue(templateId, out var monsterCard) || monsterCard.RikiId == 0) + return; + + var colorSid = ColorMaxStartSid + Math.Max(0u, monsterCard.Color - 1u); + var colorAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == colorSid); + var nextColorValue = (colorAttr?.Val ?? 0) + 1; + VirCaptureStateHelper.SetUnsignedAttr(player, colorSid, nextColorValue, sync); + + var rikiAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == RikiGroupId && x.Sid == monsterCard.RikiId); + if (rikiAttr == null) + { + rikiAttr = new Database.Player.PlayerAttr + { + Gid = RikiGroupId, + Sid = monsterCard.RikiId, + Val = 0 + }; + player.Data.Attrs.Add(rikiAttr); + } + + rikiAttr.Val += 1; + sync.Custom[player.ToPackedAttrKey(RikiGroupId, monsterCard.RikiId)] = rikiAttr.Val; + sync.Custom[player.ToShiftedAttrKey(RikiGroupId, monsterCard.RikiId)] = rikiAttr.Val; + } + + private static void ApplyCaptureExp(MikuSB.GameServer.Game.Player.PlayerInstance player, ulong templateId, NtfSyncPlayer sync) + { + if (!GameData.MonsterCardData.TryGetValue(templateId, out var monsterCard) || monsterCard.Exp == 0) + return; + + var curLevelAttr = GetOrCreateVirCaptureAttr(player, CurLevelSid); + var curExpAttr = GetOrCreateVirCaptureAttr(player, CurExpSid); + var dailyExpAttr = GetOrCreateVirCaptureAttr(player, DailyExpSid); + + var maxLevel = GameData.VirCaptureLevelListData.Count == 0 ? 1u : GameData.VirCaptureLevelListData.Keys.Max(); + var curLevel = Math.Max(1u, curLevelAttr.Val); + if (curLevel >= maxLevel) + return; + + var baseExp = monsterCard.Exp; + if (GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var currentLevelCfg) && currentLevelCfg.ExpUp > 1d) + baseExp = (uint)Math.Floor(baseExp * currentLevelCfg.ExpUp); + + var maxDailyExp = ResolveCurrentAct(player)?.MaxExp ?? 0u; + if (maxDailyExp > 0 && dailyExpAttr.Val >= maxDailyExp) + return; + + var gainExp = baseExp; + if (maxDailyExp > 0) + gainExp = Math.Min(gainExp, maxDailyExp - dailyExpAttr.Val); + + if (gainExp == 0) + return; + + dailyExpAttr.Val += gainExp; + SyncVirCaptureAttr(player, DailyExpSid, dailyExpAttr.Val, sync); + + var pendingExp = curExpAttr.Val + gainExp; + while (GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var levelCfg) && curLevel < maxLevel) + { + if (pendingExp < levelCfg.Exp) + break; + + pendingExp -= levelCfg.Exp; + curLevel++; + } + + curLevelAttr.Val = curLevel; + curExpAttr.Val = curLevel >= maxLevel + ? GameData.VirCaptureLevelListData.GetValueOrDefault(maxLevel)?.Exp ?? pendingExp + : pendingExp; + + SyncVirCaptureAttr(player, CurLevelSid, curLevelAttr.Val, sync); + SyncVirCaptureAttr(player, CurExpSid, curExpAttr.Val, sync); + } + + private static Database.Player.PlayerAttr GetOrCreateVirCaptureAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new Database.Player.PlayerAttr + { + Gid = VirCaptureGroupId, + Sid = sid, + Val = 0 + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncVirCaptureAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + sync.Custom[player.ToPackedAttrKey(VirCaptureGroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(VirCaptureGroupId, sid)] = value; + } + + private static MikuSB.Data.Excel.VirCaptureTimeExcel? ResolveCurrentAct(MikuSB.GameServer.Game.Player.PlayerInstance player) + { + var actId = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == 1)?.Val ?? 0; + if (actId > 0 && GameData.VirCaptureTimeData.TryGetValue(actId, out var act)) + return act; + + var now = DateTime.Now; + return GameData.VirCaptureTimeData.Values + .Select(x => new { Config = x, Start = ParseConfigTime(x.StartTime), End = ParseConfigTime(x.EndTime) }) + .Where(x => x.Start.HasValue && x.End.HasValue && x.Start <= now && now < x.End) + .OrderBy(x => x.Start) + .Select(x => x.Config) + .FirstOrDefault(); + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class VirCaptureSaveCaptureParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs new file mode 100644 index 0000000..1e94d3d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs @@ -0,0 +1,45 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SaveFightData")] +public class VirCaptureLevel_SaveFightData : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveFightData", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, 2u, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + + var response = new JsonObject + { + ["nLevelID"] = req.LevelId, + ["nRegionId"] = req.RegionId, + ["tbRewards"] = new JsonArray() + }; + + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveFightData", response.ToJsonString(), sync); + } +} + +internal sealed class VirCaptureSaveFightDataParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs new file mode 100644 index 0000000..a080b0e --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs @@ -0,0 +1,48 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SavePos")] +public class VirCaptureLevel_SavePos : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SavePos", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosX, req.PosX, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosY, req.PosY, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosZ, req.PosZ, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffToward, req.Toward, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SavePos", "{}", sync); + } +} + +internal sealed class VirCaptureSavePosParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nPosX")] + public int PosX { get; set; } + + [JsonPropertyName("nPosY")] + public int PosY { get; set; } + + [JsonPropertyName("nPosZ")] + public int PosZ { get; set; } + + [JsonPropertyName("nToward")] + public int Toward { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs new file mode 100644 index 0000000..371cc40 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs @@ -0,0 +1,157 @@ +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +internal static class VirCaptureStateHelper +{ + public const uint GroupId = 128; + public const uint MapDataStart = 10000; + public const uint MapDataEnd = 19000; + public const uint MaxMapCount = 3; + public const uint MaxMapDataLen = 3000; + public const uint MaxPatrolPoint = 500; + public const uint MaxOtherPoint = 2500; + public const uint MinMaterialId = 50000; + public const uint MaxMaterialId = 51500; + + public const uint OffMapId = 1; + public const uint OffTurnNum = 2; + public const uint OffPosX = 3; + public const uint OffPosY = 4; + public const uint OffPosZ = 5; + public const uint OffToward = 6; + public const uint OffDayNight = 7; + public const uint OffMapLevel = 8; + public const uint OffPatrolStart = 51; + public const uint OffPatrolEnd = 1000; + public const uint OffOtherStart = 1001; + public const uint OffOtherEnd = 1500; + public const uint OffMaterialStart = 1501; + public const uint OffMaterialEnd = 3000; + + public static uint FindOrAllocateMapSlot(PlayerInstance player, uint levelId) + { + uint? emptySlot = null; + for (uint i = 0; i < MaxMapCount; i++) + { + var slotStart = MapDataStart + (i * MaxMapDataLen); + var mapIdAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == slotStart + OffMapId); + if (mapIdAttr?.Val == levelId) + return slotStart; + + if (emptySlot == null && (mapIdAttr == null || mapIdAttr.Val == 0)) + emptySlot = slotStart; + } + + return emptySlot ?? 0; + } + + public static void EnsureBaseMapState(PlayerInstance player, uint levelId, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureUnsignedAttr(player, slotStart + OffMapId, levelId, sync); + EnsureUnsignedAttr(player, slotStart + OffDayNight, 1, sync); + EnsureUnsignedAttr(player, slotStart + OffMapLevel, 1, sync); + } + + public static void SetSignedMapOffset(PlayerInstance player, uint levelId, uint offset, int value, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureBaseMapState(player, levelId, sync); + SetUnsignedAttr(player, slotStart + offset, unchecked((uint)value), sync); + } + + public static void SetPointState(PlayerInstance player, uint levelId, uint pointId, uint value, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0 || pointId == 0) + return; + + EnsureBaseMapState(player, levelId, sync); + + if (pointId <= MaxPatrolPoint) + { + var sid = slotStart + (OffPatrolStart - 1) + pointId; + SetUnsignedAttr(player, sid, value, sync); + return; + } + + if (pointId <= MaxOtherPoint) + { + var relative = pointId - MaxPatrolPoint; + var sid = slotStart + (uint)Math.Floor(relative / 30d) + OffOtherStart; + if (sid > slotStart + OffOtherEnd) + return; + + var bit = (int)(relative % 30); + var attr = GetOrCreateAttr(player, sid); + var next = value > 0 + ? attr.Val | (1u << bit) + : attr.Val & ~(1u << bit); + if (next != attr.Val) + { + attr.Val = next; + SyncAttr(player, sync, sid, next); + } + return; + } + + if (pointId > MinMaterialId && pointId <= MaxMaterialId) + { + var sid = slotStart + (OffMaterialStart - 1) + (pointId - MinMaterialId); + if (sid >= slotStart + OffMaterialEnd) + return; + + SetUnsignedAttr(player, sid, value, sync); + } + } + + public static void EnsureUnsignedAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + public static void SetUnsignedAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + SyncAttr(player, sync, sid, value); + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs new file mode 100644 index 0000000..7e4289b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs @@ -0,0 +1,79 @@ +using MikuSB.Data; +using MikuSB.Database.Player; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureTower_EnterLevel")] +public class VirCaptureTower_EnterLevel : ICallGSHandler +{ + private const uint LaunchPassGroupId = 22; + private const uint VirCaptureGroupId = 128; + private const uint VirCaptureLevelSid = 3; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId <= 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.VirCaptureTowerData.TryGetValue((uint)req.LevelId, out var levelCfg)) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + if (!CheckConditions(player.Data, levelCfg.Condition)) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"tip.LevelLocked\"}"); + return; + } + + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"); + } + + private static bool CheckConditions(PlayerGameData data, IReadOnlyDictionary conditions) + { + foreach (var (key, value) in conditions) + { + switch (key) + { + case 1: + if (data.Level < value) + return false; + break; + case 2: + { + var pass = data.Attrs.FirstOrDefault(x => x.Gid == LaunchPassGroupId && x.Sid == value)?.Val ?? 0; + if (pass == 0) + return false; + break; + } + case 20: + { + var virLevel = data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == VirCaptureLevelSid)?.Val ?? 0; + if (virLevel < value) + return false; + break; + } + } + } + + return true; + } +} + +internal sealed class VirCaptureTowerEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs new file mode 100644 index 0000000..1f5981a --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs @@ -0,0 +1,94 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureTower_LevelSettlement")] +public class VirCaptureTower_LevelSettlement : ICallGSHandler +{ + private const uint LaunchLevelStateGroupId = 21; + private const uint LaunchPassGroupId = 22; + private const uint PassedFlagBit = 1u << 8; + private static readonly Logger Logger = new("VirCaptureTower"); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "VirCaptureTower_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId == 0) + { + Logger.Error($"Invalid vircapture tower settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + + var levelStateAttr = GetOrCreateAttr(player.Data, LaunchLevelStateGroupId, (uint)req.LevelId); + levelStateAttr.Val |= MergeStarMask(req.StarMask) | PassedFlagBit; + SyncAttr(sync, player, levelStateAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"VirCaptureTower settlement saved. uid={player.Uid} levelId={req.LevelId} starMask={req.StarMask} " + + $"levelStateVal={levelStateAttr.Val} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static uint MergeStarMask(int starMask) + { + uint result = 0; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) != 0) + result |= 1u << i; + } + + return result; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class VirCaptureTowerSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs new file mode 100644 index 0000000..16b8416 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs @@ -0,0 +1,140 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_ChangeFormation")] +public class VirCapture_ChangeFormation : ICallGSHandler +{ + private const uint StrGroupId = 57; + private const uint FormationSid = 1; + private const uint VirCaptureGroupId = 128; + private const uint CurLevelSid = 3; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var formation = ReadFormation(player); + var addId = (uint)Math.Max(0, req.Id); + var unloadId = (uint)Math.Max(0, req.UnloadId); + + var unloadIndex = unloadId == 0 ? -1 : formation.FindIndex(x => x == unloadId); + if (unloadId > 0 && unloadIndex < 0) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (addId > 0) + { + if (formation.Contains(addId)) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var addItem = player.InventoryManager.GetNormalItem(addId); + if (addItem == null || addItem.ItemType != ItemTypeEnum.TYPE_MONSTER_CARD) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + } + + if (unloadIndex >= 0) + formation.RemoveAt(unloadIndex); + + if (addId > 0) + { + if (unloadIndex >= 0 && unloadIndex <= formation.Count) + formation.Insert(unloadIndex, addId); + else + formation.Add(addId); + } + + if (!ValidateFormation(player, formation)) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var json = JsonSerializer.Serialize(formation); + player.SetStrAttr(StrGroupId, FormationSid, json); + + DatabaseHelper.SaveDatabaseType(player.Data); + + var sync = new NtfSyncPlayer(); + sync.CustomStr[player.ToShiftedAttrKey(StrGroupId, FormationSid)] = json; + + var response = new JsonObject + { + ["nId"] = req.Id, + ["nUnloadId"] = req.UnloadId, + ["bAdd"] = addId > 0 + }; + + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", response.ToJsonString(), sync); + } + + private static List ReadFormation(MikuSB.GameServer.Game.Player.PlayerInstance player) + { + var raw = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == StrGroupId && x.Sid == FormationSid)?.Val; + if (string.IsNullOrWhiteSpace(raw)) + return []; + + try + { + return JsonSerializer.Deserialize>(raw) ?? []; + } + catch + { + return []; + } + } + + private static bool ValidateFormation(MikuSB.GameServer.Game.Player.PlayerInstance player, List formation) + { + var curLevel = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == CurLevelSid)?.Val ?? 1; + if (!GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var levelCfg)) + return formation.Count == 0; + + if (formation.Count > levelCfg.Num) + return false; + + uint totalCost = 0; + foreach (var itemId in formation) + { + var item = player.InventoryManager.GetNormalItem(itemId); + if (item == null || item.ItemType != ItemTypeEnum.TYPE_MONSTER_CARD) + return false; + + if (!GameData.MonsterCardData.TryGetValue(item.TemplateId, out var monsterCfg)) + return false; + + totalCost += monsterCfg.CostValue; + } + + return totalCost <= levelCfg.MaxCost; + } +} + +internal sealed class VirCaptureChangeFormationParam +{ + [JsonPropertyName("nId")] + public int Id { get; set; } + + [JsonPropertyName("nUnloadId")] + public int UnloadId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs new file mode 100644 index 0000000..43d5465 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs @@ -0,0 +1,164 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_CheckOpenAct")] +public class VirCapture_CheckOpenAct : ICallGSHandler +{ + private const uint GroupId = 128; + private const uint ActIdSid = 1; + private const uint CurLevelSid = 3; + private const uint TrialActIdSid = 6; + private const uint SeasonActIdSid = 9; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var act = ResolveCurrent(GameData.VirCaptureTimeData.Values, now); + if (act == null) + { + await CallGSRouter.SendScript(connection, "VirCapture_CheckOpenAct", "{\"bOpen\":false}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + SetAttr(player, ActIdSid, act.Id, sync); + EnsureMinAttr(player, CurLevelSid, 1, sync); + + var response = new JsonObject + { + ["bOpen"] = true, + ["nId"] = act.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(act.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(act.EndTime)) + }; + + var season = ResolveCurrent(GameData.VirCaptureSeasonData.Values, now); + if (season != null) + { + SetAttr(player, SeasonActIdSid, season.Id, sync); + response["tbSeason"] = new JsonObject + { + ["nId"] = season.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(season.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(season.EndTime)) + }; + } + else + { + SetAttr(player, SeasonActIdSid, 0, sync); + } + + var trial = ResolveCurrent(GameData.VirCaptureTrialTimeData.Values, now); + SetAttr(player, TrialActIdSid, trial?.Id ?? 0, sync); + + await CallGSRouter.SendScript(connection, "VirCapture_CheckOpenAct", response.ToJsonString(), sync); + } + + private static T? ResolveCurrent(IEnumerable configs, DateTime now) where T : class + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(GetTimeValue(x, true)), + End = ParseConfigTime(GetTimeValue(x, false)) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null && latestStarted.End > latestStarted.Start) + return latestStarted.Config; + + return null; + } + + private static string? GetTimeValue(T value, bool start) where T : class + { + return value switch + { + VirCaptureTimeExcel time => start ? time.StartTime : time.EndTime, + VirCaptureSeasonExcel season => start ? season.StartTime : season.EndTime, + _ => null + }; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; + } + + private static void EnsureMinAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + SyncAttr(player, sync, sid, value); + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs new file mode 100644 index 0000000..9fa0ffa --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs @@ -0,0 +1,292 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_GetLevelAward")] +public class VirCapture_GetLevelAward : ICallGSHandler +{ + private const uint VirCaptureGroupId = 128; + private const uint CurLevelSid = 3; + private const uint LevelAwardFlagStartSid = 101; + private const uint LevelAwardFlagEndSid = 120; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.IdList == null || req.IdList.Count == 0) + { + await CallGSRouter.SendScript(connection, "VirCapture_GetLevelAward", "{\"tbAwardList\":[]}"); + return; + } + + var curLevel = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == CurLevelSid)?.Val ?? 0; + var requestedLevels = req.IdList + .Where(x => x > 0) + .Select(x => (uint)x) + .Distinct() + .OrderBy(x => x) + .ToList(); + + var claimLevels = requestedLevels + .Where(level => level <= curLevel && CanClaimLevel(player.Data, level)) + .ToList(); + + var sync = new NtfSyncPlayer(); + var responseAwards = new JsonArray(); + + foreach (var level in claimLevels) + { + if (!GameData.VirCaptureLevelListData.TryGetValue(level, out var levelCfg) || + levelCfg.Rewards.Count == 0) + { + continue; + } + + SetClaimed(player, sync, level); + + foreach (var reward in levelCfg.Rewards) + { + if (reward.Count < 5) + continue; + + await GrantRewardAsync(player, sync, reward); + responseAwards.Add(new JsonArray( + (int)reward[0], + (int)reward[1], + (int)reward[2], + (int)reward[3], + (int)reward[4])); + } + } + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var rsp = new JsonObject + { + ["tbAwardList"] = responseAwards + }; + await CallGSRouter.SendScript(connection, "VirCapture_GetLevelAward", rsp.ToJsonString(), sync); + } + + private static bool CanClaimLevel(PlayerGameData data, uint level) + { + var sid = GetLevelAwardSid(level); + if (sid < LevelAwardFlagStartSid || sid > LevelAwardFlagEndSid) + return false; + + var pos = GetLevelAwardBit(level); + var attr = data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == sid); + return ((attr?.Val ?? 0) & (1u << pos)) == 0; + } + + private static void SetClaimed(PlayerInstance player, NtfSyncPlayer sync, uint level) + { + var sid = GetLevelAwardSid(level); + var pos = GetLevelAwardBit(level); + var attr = GetOrCreateAttr(player.Data, VirCaptureGroupId, sid); + attr.Val |= 1u << pos; + sync.Custom[player.ToPackedAttrKey(VirCaptureGroupId, sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(VirCaptureGroupId, sid)] = attr.Val; + } + + private static uint GetLevelAwardSid(uint level) => LevelAwardFlagStartSid + (level / 30); + + private static int GetLevelAwardBit(uint level) => (int)(level % 30); + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid, + Val = 0 + }; + data.Attrs.Add(attr); + return attr; + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } +} + +internal sealed class VirCaptureGetLevelAwardParam +{ + [JsonPropertyName("nId")] + public int ActId { get; set; } + + [JsonPropertyName("tbIdList")] + public List IdList { get; set; } = []; +} diff --git a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs index 5f719ce..3b39552 100644 --- a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs +++ b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs @@ -21,6 +21,7 @@ namespace MikuSB.GameServer.Server.Packet.Recv.Login; public class HandlerReqLogin : Handler { private static readonly Logger Logger = new("ReqLogin"); + private const int SupportCardLoginSplitThreshold = 2000; private static string? ExtractSdkAuthToken(string? token) { @@ -46,6 +47,9 @@ public class HandlerReqLogin : Handler } } + private static AccountData? ResolveAutoLoginAccount() + => AccountData.GetFirstAccount(); + public override async Task OnHandle(Connection connection, byte[] data, ushort seqNo) { var req = ReqLogin.Parser.ParseFrom(data); @@ -56,9 +60,14 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s ?? AccountData.GetAccountByDispatchToken(sdkAuthToken ?? ""); if (account == null) { - Logger.Warn($"Rejected login: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}"); - await connection.SendPacket(CmdIds.NtfLogout); - return; + account = ResolveAutoLoginAccount(); + if (account == null) + { + Logger.Warn($"Rejected login: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}, reason=no account exists"); + await connection.SendPacket(CmdIds.NtfLogout); + return; + } + Logger.Warn($"Auto login accepted with first account: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}, username={account.Username}, uid={account.Uid}"); } if (!ResourceManager.IsLoaded) // resource manager not loaded, return @@ -80,7 +89,10 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s $"Debug-{DateTime.Now:yyyy-MM-dd HH-mm-ss}.log"); await connection.Player.OnEnterGame(); connection.Player.Connection = connection; - await connection.SendPacket(new PacketRspLogin(connection.Player!)); + var splitSupportCards = connection.Player.InventoryManager.InventoryData.SupportCards.Count > SupportCardLoginSplitThreshold; + await connection.SendPacket(new PacketRspLogin(connection.Player!, !splitSupportCards)); + if (splitSupportCards) + await SendSupportCardsOnLogin(connection); await connection.SendPacket(new PacketNtfCallScript(connection.Player!)); await SendDebugLoginState(connection); @@ -90,6 +102,22 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s await SendGirlSkinTypeOnLogin(connection); } + private static async Task SendSupportCardsOnLogin(Connection connection) + { + var player = connection.Player; + if (player == null) + return; + + var supportCards = player.InventoryManager.InventoryData.SupportCards.Values.ToList(); + Logger.Info($"Split support card sync on login: total={supportCards.Count}, chunkSize={SupportCardLoginSplitThreshold}"); + + foreach (var chunk in supportCards.Chunk(SupportCardLoginSplitThreshold)) + { + var packet = new PacketNtfCallScript(chunk.ToList()); + await connection.SendPacket(packet); + } + } + private static void ApplySavedGirlSkinTypes(PlayerInstance player) { var inventoryData = player.InventoryManager.InventoryData; diff --git a/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs index c3bec83..45805bc 100644 --- a/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs +++ b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs @@ -10,18 +10,41 @@ public class PacketRspLogin : BasePacket { private static readonly Logger Logger = new("RspLogin"); - public PacketRspLogin(PlayerInstance player) : base(CmdIds.RspLogin) + public PacketRspLogin(PlayerInstance player, bool includeSupportCards = true) : base(CmdIds.RspLogin) { + var characterCount = player.CharacterManager.CharacterData.Characters.Count; + var itemCount = player.InventoryManager.InventoryData.Items.Count; + var skinCount = player.InventoryManager.InventoryData.Skins.Count; + var weaponCount = player.InventoryManager.InventoryData.Weapons.Count; + var supportCardCount = player.InventoryManager.InventoryData.SupportCards.Count; + var attrCount = player.Data.Attrs.Count; + var strAttrCount = player.Data.StrAttrs.Count; + var showItemCount = player.Data.ShowItems.Count; + var proto = new RspLogin { Timestamp = (uint)Extensions.GetUnixSec(), WorldChannel = 1, AreaId = 1, - Data = player.ToPlayerProto(), + Data = player.ToPlayerProto(includeSupportCards), NeedRename = false }; var bytes = Google.Protobuf.MessageExtensions.ToByteArray(proto); + Logger.Info( + "RspLogin content: " + + $"characters={characterCount}, " + + $"items={itemCount}, " + + $"skins={skinCount}, " + + $"weapons={weaponCount}, " + + $"supportCards={supportCardCount}, " + + $"supportCardsInRspLogin={(includeSupportCards ? supportCardCount : 0)}, " + + $"attrs={attrCount}, " + + $"strAttrs={strAttrCount}, " + + $"showItems={showItemCount}, " + + $"protoItems={proto.Data.Items.Count}, " + + $"protoAttrs={proto.Data.Attrs.Count}, " + + $"protoStrAttrs={proto.Data.StrAttrs.Count}"); Logger.Info($"RspLogin proto size: {bytes.Length} bytes"); SetData(bytes); diff --git a/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs b/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs index 7e65044..8dd4e5e 100644 --- a/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs +++ b/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs @@ -126,6 +126,8 @@ public PacketNtfCallScript(PlayerInstance Player) : base(CmdIds.NtfScript) sync.Custom[Player.ToPackedAttrKey(gid, sid)] = val; sync.Custom[Player.ToShiftedAttrKey(gid, sid)] = val; } + foreach (var (key, value) in Player.BuildMoneySync()) + sync.Money[key] = value; proto.ExtraSync = sync; SetData(proto); diff --git a/MikuSB/Program/LoaderManager.cs b/MikuSB/Program/LoaderManager.cs index 6590a72..7ab98ab 100644 --- a/MikuSB/Program/LoaderManager.cs +++ b/MikuSB/Program/LoaderManager.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Components; using MikuSB.Data; using MikuSB.Database; +using MikuSB.Database.Account; using MikuSB.GameServer.Command; using MikuSB.GameServer.Server; using MikuSB.GameServer.Server.CallGS; @@ -19,6 +20,9 @@ namespace MikuSB.MikuSB.Program; public class LoaderManager : MikuSB { + private const string InitialAccountUsername = "MIKU"; + private const int InitialAccountUid = 1; + public static void InitConfig() { // Initialize log @@ -99,6 +103,11 @@ public static void InitConfig() public static void InitDatabase() { + var databaseFile = new FileInfo(Path.Combine( + ConfigManager.Config.Path.DatabasePath, + ConfigManager.Config.GameServer.DatabaseName)); + var shouldInitializeData = !databaseFile.Exists; + // Initialize the database try { @@ -106,6 +115,9 @@ public static void InitDatabase() while (!DatabaseHelper.LoadAccount) Thread.Sleep(100); + if (shouldInitializeData) + InitializeStartupData(); + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItem", I18NManager.Translate("Word.DatabaseAccount"))); } @@ -118,6 +130,16 @@ public static void InitDatabase() } } + private static void InitializeStartupData() + { + if (AccountData.GetFirstAccount() != null) + return; + + var startupPassword = Crypto.CreateSessionKey($"{InitialAccountUsername}-{DateTime.UtcNow.Ticks}"); + _ = AccountData.CreateAccount(InitialAccountUsername, InitialAccountUid, startupPassword); + Logger.Info("Initialized startup account for fresh database."); + } + public static async Task InitSdkServer() { SdkServer.SdkServer.Start([]); @@ -199,4 +221,4 @@ public static async Task InitCommand(CancellationToken exitToken) await IConsole.ListenConsole(exitToken); } -} \ No newline at end of file +} diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index ff779d5..bb98747 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -1,5 +1,6 @@ using MikuSB.Data; using MikuSB.Database; +using MikuSB.Loader; using MikuSB.MikuSB.Tool; using MikuSB.GameServer.Command; using MikuSB.GameServer.Server; @@ -22,14 +23,17 @@ public class MikuSB private static readonly CancellationTokenSource _cts = new(); private static int _exitCode = 0; - public static async Task Main() + public static async Task Main(string[] args) { + Directory.SetCurrentDirectory(AppContext.BaseDirectory); var time = DateTime.Now; IConsole.InitConsole(); LoaderManager.InitConfig(); if (await UpdateService.TryStartSelfUpdateAsync()) return; + TryRunStartupGame(args); + RegisterExitEvent(); await LoaderManager.InitSdkServer(); LoaderManager.InitPacket(); @@ -65,8 +69,62 @@ public static async Task Main() await ProcessExit(Volatile.Read(ref _exitCode)); } + private static void ShowAntiScamWarning() + { + Logger.Warn("============================================================"); + Logger.Warn("MikuSB is completely free and open source."); + Logger.Warn("If you paid anyone for this server, you were scammed."); + Logger.Warn("Request a refund immediately and report the seller to us."); + Logger.Warn("Discord: https://discord.gg/aMwCu9JyUR"); + Logger.Warn("============================================================"); + } + #region Exit + private static void TryRunStartupGame(string[] args) + { + if (!args.Any(x => string.Equals(x, "-game", StringComparison.OrdinalIgnoreCase))) + return; + try + { + var extraGameArgs = ParseGameCommandArgs(args); + var pid = GameLaunchService.Launch(extraGameArgs); + Logger.Info(I18NManager.Translate("Game.Command.Game.Started", pid.ToString(CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + Logger.Error(I18NManager.Translate("Game.Command.Game.Failed", ex.Message), ex); + } + } + private static string[] ParseGameCommandArgs(string[] args) + { + var result = new List(); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + // skip launcher flag itself + if (string.Equals(arg, "-game", StringComparison.OrdinalIgnoreCase)) + continue; + + // everything after -- will be forwarded directly + if (string.Equals(arg, "--", StringComparison.Ordinal)) + { + for (var j = i + 1; j < args.Length; j++) + result.Add(args[j]); + + break; + } + + // optional: + // support quoted args that shell already split incorrectly + if (!string.IsNullOrWhiteSpace(arg)) + result.Add(arg); + } + + return result.ToArray(); + } private static void RegisterExitEvent() { AppDomain.CurrentDomain.ProcessExit += (_, _) => @@ -109,4 +167,5 @@ private static async Task ProcessExit(int exitCode) } # endregion -} \ No newline at end of file +} + diff --git a/MikuSB/Update/UpdateService.cs b/MikuSB/Update/UpdateService.cs index 1c6f2d9..227ada0 100644 --- a/MikuSB/Update/UpdateService.cs +++ b/MikuSB/Update/UpdateService.cs @@ -11,7 +11,6 @@ namespace MikuSB.MikuSB.Update; public static class UpdateService { private static readonly Logger Logger = new("Updater"); - private static readonly bool UpdateEnabled = true; private static readonly bool AskBeforeUpdate = true; private static readonly string RepositoryOwner = "MikuLeaks"; private static readonly string RepositoryName = "MikuSB"; @@ -30,8 +29,11 @@ public static class UpdateService public static async Task TryStartSelfUpdateAsync() { - if (!UpdateEnabled) + if (!ConfigManager.Config.ServerOption.EnableAutoUpdate) + { + Logger.Debug("Auto update skipped because it is disabled in Config.json."); return false; + } if (string.IsNullOrWhiteSpace(RepositoryOwner) || string.IsNullOrWhiteSpace(RepositoryName) @@ -167,6 +169,7 @@ private static bool AreRequiredResourcesPresent() return RequiredResourceFiles.All(fileName => File.Exists(Path.Combine(resourcePath, fileName))); } + private static async Task DownloadAndInstallResourcesAsync() { using var client = CreateHttpClient(); diff --git a/README.md b/README.md index 1908731..cd366e0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,36 @@ # MikuSB -MikuSB is a server emulator of a certain dungeon anime game. -`SdkServer`, `GameServer`, and an optional local HTTP/HTTPS proxy are started from a single `net9.0` application. +Languages: English | [中文](docs/user/README_zh.md) | [日本語](docs/user/README_jp.md) + +MikuSB is a server emulator of a certain dungeon anime game. +`SdkServer`, `GameServer`, and an optional local HTTP/HTTPS proxy are started from a single `net10.0` application. [Discord](https://discord.gg/aMwCu9JyUR) -日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 +## Documentation + +- [Linux guide](docs/user/platform/README_linux_en.md) +- [Usage guide](docs/user/usage/USAGE_en.md) +- [Command guide](docs/user/commands/COMMAND_GUIDE_en.md) +- [Command target notes](docs/user/commands/COMMAND_TARGET_en.md) + +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. + +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. + +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. ## Overview @@ -37,58 +62,71 @@ ## Running 1. Restore dependencies and build. + ```powershell dotnet build ``` -2. Set `GamePath` in `Config.json` to the path of your game executable. -3. Start the server and run the `game` command. -4. Create an account in the server console. -5. Enjoy. +2. Set `GamePath` in `Config/Config.json` to the path of your game executable. +3. Start the server. + +```powershell +dotnet run --project .\MikuSB +``` + +4. Create an account in the server console. +5. Run the `game` command in the server console (arguments are passed through to the game process). +6. Start the game and log in, or launch directly with `MikuSB.exe -game [-path game_path] [-arg param1] [-arg param2]`. + +For publish commands and generated data details, see the [usage guide](docs/user/usage/USAGE_en.md). ## Feature List -* [x] Login and basic account entry -* [x] Player data loading -* [x] Inventory loading -* [x] Character loading -* [x] Skin loading -* [x] Weapon loading -* [x] Lobby display character switching -* [x] Character skin switching -* [x] Character skin form switching -* [x] Weapon replacement -* [x] Weapon upgrade -* [x] Player rename -* [x] Basic saving of currently supported lobby state -* [✓] Main chapter stage entry and related flow -* [✓] Daily stage entry and related flow -* [✓] Basic player setting synchronization -* [✓] Basic profile synchronization -* [✓] Activity-related requests -* [✓] Achievement-related requests -* [✓] Lineup-related requests -* [✓] Preview-related requests -* [✓] Some shop-related requests -* [ ] Full combat flow -* [ ] Mission / quest progression -* [ ] Gacha / recruitment systems -* [ ] Complete shop behavior -* [ ] Multiplayer systems -* [ ] Base / dorm systems -* [ ] Full client API coverage +- [x] Login and basic account entry +- [x] Player data loading +- [x] Inventory loading +- [x] Character loading +- [x] Skin loading +- [x] Weapon loading +- [x] Lobby display character switching +- [x] Character skin switching +- [x] Character skin form switching +- [x] Weapon replacement +- [x] Weapon upgrade +- [x] Player rename +- [x] Basic saving of currently supported lobby state +- [x] Main chapter stage entry and related flow +- [x] Daily stage entry and related flow +- [x] Basic player setting synchronization +- [x] Basic profile synchronization +- [x] Activity-related requests +- [x] Achievement-related requests +- [x] Lineup-related requests +- [x] Preview-related requests +- [x] Some shop-related requests +- [ ] Full combat flow +- [ ] Mission / quest progression +- [ ] Gacha / recruitment systems +- [ ] Complete shop behavior +- [ ] Multiplayer systems +- [ ] Base / dorm systems +- [ ] Full client API coverage ## Contributors + - [Naruse](https://github.com/DevilProMT) - [Kei-Luna](https://github.com/Kei-Luna) ## Notes on use -This software is intended for research and testing purposes in a local environment. + +This software is intended for research and testing purposes in a local environment. It is not intended for unauthorized access to, interference with, or commercial use of official services. ## Legal Disclaimer -MikuSB was developed for educational and research purposes. -- All trademarks, copyrights, and other intellectual property related to the original game and its associated franchise belong to their respective owners. -- This repository does not include any copyrighted game assets, binaries, or master data. -- Use this software at your own risk. The authors assume no responsibility for any damages or legal consequences resulting from its use. + +MikuSB was developed for educational and research purposes. + +- All trademarks, copyrights, and other intellectual property related to the original game and its associated franchise belong to their respective owners. +- This repository does not include any copyrighted game assets, binaries, or master data. +- Use this software at your own risk. The authors assume no responsibility for any damages or legal consequences resulting from its use. If you are a rights holder and have any concerns regarding this software, please contact `devilpromt` or `kei_luna` on Discord. diff --git a/README_jp.md b/README_jp.md deleted file mode 100644 index 17018d0..0000000 --- a/README_jp.md +++ /dev/null @@ -1,95 +0,0 @@ -# MikuSB - -MikuSBは、あるダンジョンアニメゲームのサーバーエミュレーターです。 -`SdkServer`、`GameServer`、任意のローカル HTTP/HTTPS プロキシを 1 つの `net9.0` アプリとして起動します。 - -[Discord](https://discord.gg/aMwCu9JyUR) - -English documentation is available in [README.md](README.md). - -## 概要 - -- `SdkServer` - - HTTP API とディスパッチを返します - - サーバー一覧、バージョン照会、各種フォールバックレスポンスを返します -- `GameServer` - - TCP ベースのゲーム接続を受けます - - `ReqCallGS` と一部の通常パケットを処理します -- `Proxy` - - 有効時のみ `127.0.0.1:8888` で待ち受けます - - 一部の Snowbreak 関連ドメインをローカル `SdkServer` へリダイレクトします -- `Common` / `Proto` / `TcpSharp` - - 共通データ、protobuf 定義、通信基盤です - -## プロジェクト構成 - -- [MikuSB](MikuSB): エントリーポイント -- [SdkServer](SdkServer): HTTP サーバーとディスパッチ -- [GameServer](GameServer): ゲームサーバー本体 -- [Proxy](Proxy): ローカルプロキシ -- [Common](Common): 設定、DB、共通処理 -- [Proto](Proto): protobuf 定義 - -## 要件 - -- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) - -## 起動方法 - -1. 依存を復元してビルドします。 -```powershell -dotnet build -``` -2. Config.json の`GamePath`にあなたのゲームの実行ファイルのパスを書き込みます -3. サーバーを起動し`game`コマンドを入力します -4. サーバーコンソールでアカウントを作成する -5. 楽しむ - -## 機能一覧 - -* [x] ログインと基本的なアカウント入場 -* [x] プレイヤーデータの読み込み -* [x] 所持品の読み込み -* [x] キャラクターの読み込み -* [x] スキンの読み込み -* [x] 武器の読み込み -* [x] ロビー表示キャラクターの変更 -* [x] キャラクタースキンの変更 -* [x] キャラクタースキン形態の変更 -* [x] 武器の付け替え -* [x] 武器の強化 -* [x] プレイヤー名の変更 -* [x] 現在対応済みロビー状態の基本保存 -* [✓] メイン章のステージ入場と関連フロー -* [✓] デイリーのステージ入場と関連フロー -* [✓] 基本的なプレイヤー設定同期 -* [✓] 基本的なプロフィール同期 -* [✓] イベント関連リクエスト -* [✓] 実績関連リクエスト -* [✓] 編成関連リクエスト -* [✓] プレビュー関連リクエスト -* [✓] 一部のショップ関連リクエスト -* [ ] 完全な戦闘フロー -* [ ] ミッション / クエスト進行 -* [ ] ガチャ / 募集システム -* [ ] 完全なショップ挙動 -* [ ] マルチプレイシステム -* [ ] 基地 / 宿舎システム -* [ ] クライアント API 全体の対応 - -## 貢献者 -- [Naruse](https://github.com/DevilProMT) -- [Kei-Luna](https://github.com/Kei-Luna) - - -## 利用上の注意 -本ソフトウェアはローカル環境での研究・検証用途を想定しています。 -公式サービスへの不正な接続、妨害、または商用利用を意図したものではありません。 - -## 法的免責事項 -MikuSBは教育および研究目的で開発されました。 -- 元のゲーム及び関連フランチャイズに関するすべての商標、著作権知的財産権はそれぞれの所有者に帰属します。 -- このリポジトリには、著作権で保護されたゲームアセット、バイナリ、マスターデータは一切含まれていません。 -- 自己責任でご利用下さい。 著者は、本ソフトウェアによって生じるいかなる損害または法的結果についても一切責任を負いません。 - -本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 \ No newline at end of file diff --git a/README_linux.md b/README_linux.md deleted file mode 100644 index 36b910b..0000000 --- a/README_linux.md +++ /dev/null @@ -1,66 +0,0 @@ -# MikuSB on Linux - - -## Config - -### setup steam launch options as following - -`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` - -### start local server and keep it running - -``` -./MikuSB -``` - -### find root CA cert, and create ca bundle - -root CA cert, should in the path: `proxy-certs/MikuSB.Proxy.Root.pem` - - -### setup root CA for proton/wine - -not sure, even I remove Proton PFX (Wine prefix) folder, without redo this step, still no cert issue. - -`Proton Hotfix` is the proton version which selected in steam `Force the use of a specific Steam Play compatibility tool` - -```bash -APPID= -STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx -STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" -WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem -``` - -### start the game and enjoy - - -## development - -1. Restore dependencies and build. - -```bash -dotnet build -``` - -2. run it - -```bash -dotnet run --project ./MikuSB -``` - -## release build - -```bash -DOTNET_CLI_UI_LANGUAGE=en time dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish - -# output will in ./publish/* -cd ./publish - -# start server -./MikuSB -``` - -## TODO: - -* [ ] tool/script for CA cert create and install to proton/wine -* [ ] automatic done in main program diff --git a/SdkServer/Handlers/RouteController.cs b/SdkServer/Handlers/RouteController.cs index d8ea8c9..0d6e86a 100644 --- a/SdkServer/Handlers/RouteController.cs +++ b/SdkServer/Handlers/RouteController.cs @@ -190,6 +190,9 @@ public IActionResult GetSeasunConfig() } } + private static AccountData? ResolveAutoLoginAccount() + => AccountData.GetFirstAccount(); + private IActionResult BuildLoginFailedResponse(string message) { object rsp = new @@ -220,12 +223,12 @@ public async Task LoginByToken( [FromQuery] string? uid, [FromQuery] string? token, [FromForm] string? form_uid, - [FromForm] string? form_token - ) + [FromForm] string? form_token) { - var finalUid = uid ?? form_uid ?? await GetJsonBodyValue("uid"); - var finalToken = token ?? form_token ?? await GetJsonBodyValue("token"); - var account = ResolveAccountForSdkLogin(null, finalUid, finalToken); + var bodyUid = await GetJsonBodyValue("uid"); + var bodyToken = await GetJsonBodyValue("token"); + var account = ResolveAccountForSdkLogin(null, uid ?? form_uid ?? bodyUid, token ?? form_token ?? bodyToken) + ?? ResolveAutoLoginAccount(); if (account == null) return BuildLoginFailedResponse("Account not found."); @@ -264,10 +267,10 @@ public async Task Login( [FromQuery] string? email, [FromForm] string? form_uid, [FromForm] string? form_token, - [FromForm] string? form_email - ) + [FromForm] string? form_email) { - var finalEmail = email ?? form_email ?? await GetJsonBodyValue("email"); + var bodyEmail = await GetJsonBodyValue("email"); + var finalEmail = email ?? form_email ?? bodyEmail; if (!string.IsNullOrWhiteSpace(finalEmail)) { var normalizedEmail = finalEmail.Trim(); @@ -306,9 +309,10 @@ [FromForm] string? form_email return Ok(emailLoginRsp); } - var finalUid = uid ?? form_uid ?? await GetJsonBodyValue("uid"); - var finalToken = token ?? form_token ?? await GetJsonBodyValue("token"); - var account = ResolveAccountForSdkLogin(finalEmail, finalUid, finalToken); + var bodyUid = await GetJsonBodyValue("uid"); + var bodyToken = await GetJsonBodyValue("token"); + var account = ResolveAccountForSdkLogin(finalEmail, uid ?? form_uid ?? bodyUid, token ?? form_token ?? bodyToken) + ?? ResolveAutoLoginAccount(); if (account == null) return BuildLoginFailedResponse("Account not found."); diff --git a/docs/dev/BRANCH_UPDATE_SUMMARY_en.md b/docs/dev/BRANCH_UPDATE_SUMMARY_en.md new file mode 100644 index 0000000..d63aecf --- /dev/null +++ b/docs/dev/BRANCH_UPDATE_SUMMARY_en.md @@ -0,0 +1,74 @@ +# Branch update summary (based on origin/main) + +> Branch: `copilot/analyze-login-rejection` +> Baseline: `origin/main` +> Stats: 13 commits, 5 files changed (+163 / -141) + +## 1. Commit list (oldest to newest) + +1. `038e236` feat: force login to MIKU account +2. `8ea8b75` fix: handle forced account fallback exceptions +3. `13c7ed8` refactor: share forced account resolution for login +4. `89c8e81` feat: auto login first account from database +5. `1d217f0` refactor: select first account for auto-login fallback +6. `60b091d` feat: initialize default account at startup for new database +7. `f502007` fix: avoid logging startup account identifiers +8. `fbb31c8` fix: use random password for startup-initialized account +9. `2ca4f0a` feat: auto grant level 90 weapons on new player initialization +10. `61a231f` feat: initialize all giveall items for new players +11. `80fbf48` fix: backfill full player initialization on empty login data +12. `25c76c2` fix: require exactly three characters before default lineup init +13. `8582e3c` fix: set bootstrap equipment and character progression to level 80 + +## 2. File-level overview + +### 1) `Common/Database/Account/AccountData.cs` +- Added `GetFirstAccount()`: + - Selects the first account by ascending `Uid`. + - Serves as the shared fallback for auto-login. + +### 2) `MikuSB/Program/LoaderManager.cs` +- Added startup initialization for fresh databases: + - Detects first-run by checking the database file. + - Calls `InitializeStartupData()` on first run. + - Creates a default account `MIKU` (`Uid=1`) when no account exists. + - Initial password is a random session key. + +### 3) `SdkServer/Handlers/RouteController.cs` +- SDK login logic is consolidated to the “first account auto-login” path: + - `/seasun/login` + - `/seasun/loginByToken` +- Removed the earlier token/email/uid multi-source resolution path. +- Returns a unified login-failed response when no account is found. + +### 4) `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- Added auto fallback on login packet handling: + - If token/dispatch/combo resolution fails, fallback to `GetFirstAccount()`. + - Reject login if no account exists. + - Continue login flow if an account is available. + +### 5) `GameServer/Game/Player/PlayerInstance.cs` +- Added and reused `InitializeAllDatabaseData()`: + - Covers weapons, support cards, skins, profiles, accessories, furniture, AR, manifestation, characters, and supplies. + - Used for both new player creation and empty-login backfill. +- Added `ShouldBackfillAllDatabaseData()`: + - Triggers full backfill when characters and key inventory are empty. +- Default lineup init is guarded: + - Only writes lineup when exactly three characters are selected. +- Bootstrap level is unified: + - Constant `BootstrapLevel = 80`. + +## 3. Core themes vs main + +1. **Login resilience**: fallback to the first account when tokens do not match. +2. **Fresh DB usability**: auto-create a default account on first start. +3. **Player data self-healing**: backfill full data when a login has empty records. +4. **Unified bootstrap rules**: consolidated initial level configuration. + +## 4. Changed files + +- `Common/Database/Account/AccountData.cs` +- `GameServer/Game/Player/PlayerInstance.cs` +- `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- `MikuSB/Program/LoaderManager.cs` +- `SdkServer/Handlers/RouteController.cs` diff --git a/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md b/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md new file mode 100644 index 0000000..a3eff29 --- /dev/null +++ b/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md @@ -0,0 +1,74 @@ +# 本分支基于主线(origin/main)更新总结 + +> 分支:`copilot/analyze-login-rejection` +> 对比基线:`origin/main` +> 统计:13 个提交,5 个文件变更(+163 / -141) + +## 一、提交列表(按时间从旧到新) + +1. `038e236` feat: force login to MIKU account +2. `8ea8b75` fix: handle forced account fallback exceptions +3. `13c7ed8` refactor: share forced account resolution for login +4. `89c8e81` feat: auto login first account from database +5. `1d217f0` refactor: select first account for auto-login fallback +6. `60b091d` feat: initialize default account at startup for new database +7. `f502007` fix: avoid logging startup account identifiers +8. `fbb31c8` fix: use random password for startup-initialized account +9. `2ca4f0a` feat: auto grant level 90 weapons on new player initialization +10. `61a231f` feat: initialize all giveall items for new players +11. `80fbf48` fix: backfill full player initialization on empty login data +12. `25c76c2` fix: require exactly three characters before default lineup init +13. `8582e3c` fix: set bootstrap equipment and character progression to level 80 + +## 二、文件级更新概览 + +### 1) `Common/Database/Account/AccountData.cs` +- 新增 `GetFirstAccount()`: + - 从账号表中按 `Uid` 升序选择首个账号; + - 作为自动登录回退账号解析的统一入口。 + +### 2) `MikuSB/Program/LoaderManager.cs` +- 在数据库初始化阶段新增“新库首启数据初始化”逻辑: + - 通过数据库文件存在性判断是否为首次初始化; + - 首次初始化时调用 `InitializeStartupData()`; + - 若库中无账号,则自动创建默认账号 `MIKU`(`Uid=1`); + - 初始密码改为随机生成(会话密钥形式)。 + +### 3) `SdkServer/Handlers/RouteController.cs` +- 收敛 SDK 登录相关分支逻辑到“首账号自动登录”路径: + - `/seasun/login` + - `/seasun/loginByToken` +- 移除原有较复杂的 token/email/uid 多源解析代码路径; +- 在找不到账号时统一返回登录失败响应。 + +### 4) `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- 登录包处理增加自动回退: + - token/dispatch/combo 解析失败后,回退到 `GetFirstAccount()`; + - 若无可用账号则拒绝登录; + - 有可用账号则继续登录流程。 + +### 5) `GameServer/Game/Player/PlayerInstance.cs` +- 新增并复用完整初始化方法 `InitializeAllDatabaseData()`: + - 覆盖武器、支援卡、皮肤、资料、挂件、家具、AR、显现、角色、补给等初始化; + - 统一用于“新玩家创建”与“空数据登录回填”。 +- 新增 `ShouldBackfillAllDatabaseData()`: + - 当角色与关键库存均为空时触发全量回填。 +- 默认阵容初始化增加防护: + - 仅在随机选出**恰好 3 名角色**时执行阵容写入。 +- 角色引导等级调整: + - 统一常量 `BootstrapLevel = 80`。 + +## 三、本分支相对主线的核心变化主题 + +1. **登录容错增强**:token 无法匹配时可回退首账号,减少首次接入/异常数据导致的登录拒绝。 +2. **新库可开箱运行**:首次启动自动生成默认账号,降低初始化门槛。 +3. **玩家数据自愈能力提升**:对空档案进行登录时全量回填,避免关键数据缺失。 +4. **初始化规则统一化**:引导等级配置集中化,行为更稳定可控。 + +## 四、变更文件清单 + +- `Common/Database/Account/AccountData.cs` +- `GameServer/Game/Player/PlayerInstance.cs` +- `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- `MikuSB/Program/LoaderManager.cs` +- `SdkServer/Handlers/RouteController.cs` diff --git a/docs/user/README_jp.md b/docs/user/README_jp.md new file mode 100644 index 0000000..e17ce2d --- /dev/null +++ b/docs/user/README_jp.md @@ -0,0 +1,115 @@ +# MikuSB + +Languages: [English](../../README.md) | [中文](README_zh.md) | 日本語 + +MikuSBは、あるダンジョンアニメゲームのサーバーエミュレーターです。 +`SdkServer`、`GameServer`、任意のローカル HTTP/HTTPS プロキシを 1 つの `net10.0` アプリとして起動します。 + +[Discord](https://discord.gg/aMwCu9JyUR) + +## ドキュメント + +- [Linux ガイド](platform/README_linux_jp.md) +- [使用ガイド](usage/USAGE_jp.md) +- [コマンドガイド](commands/COMMAND_GUIDE_jp.md) +- [コマンド対象説明](commands/COMMAND_TARGET_jp.md) + +## 概要 + +- `SdkServer` + - HTTP API とディスパッチを返します + - サーバー一覧、バージョン照会、各種フォールバックレスポンスを返します +- `GameServer` + - TCP ベースのゲーム接続を受けます + - `ReqCallGS` と一部の通常パケットを処理します +- `Proxy` + - 有効時のみ `127.0.0.1:8888` で待ち受けます + - 一部の Snowbreak 関連ドメインをローカル `SdkServer` へリダイレクトします +- `Common` / `Proto` / `TcpSharp` + - 共通データ、protobuf 定義、通信基盤です + +## プロジェクト構成 + +- [MikuSB](../../MikuSB): エントリーポイント +- [SdkServer](../../SdkServer): HTTP サーバーとディスパッチ +- [GameServer](../../GameServer): ゲームサーバー本体 +- [Proxy](../../Proxy): ローカルプロキシ +- [Common](../../Common): 設定、DB、共通処理 +- [Proto](../../Proto): protobuf 定義 + +## 要件 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) + +## 起動方法 + +1. 依存関係を復元してビルドします。 + +```powershell +dotnet build +``` + +2. `Config/Config.json` の `GamePath` にゲーム実行ファイルのパスを設定します。 +3. サーバーを起動します。 + +```powershell +dotnet run --project .\MikuSB +``` + +4. サーバーコンソールでアカウントを作成します。 +5. サーバーコンソールで `game` コマンドを実行します。 +6. ゲームを起動してログインします。 + +公開コマンドと生成データの詳細は[使用ガイド](usage/USAGE_jp.md)を参照してください。 + +## 機能一覧 + +- [x] ログインと基本的なアカウント入場 +- [x] プレイヤーデータの読み込み +- [x] 所持品の読み込み +- [x] キャラクターの読み込み +- [x] スキンの読み込み +- [x] 武器の読み込み +- [x] ロビー表示キャラクターの変更 +- [x] キャラクタースキンの変更 +- [x] キャラクタースキン形態の変更 +- [x] 武器の付け替え +- [x] 武器の強化 +- [x] プレイヤー名の変更 +- [x] 現在対応済みロビー状態の基本保存 +- [x] メイン章のステージ入場と関連フロー +- [x] デイリーのステージ入場と関連フロー +- [x] 基本的なプレイヤー設定同期 +- [x] 基本的なプロフィール同期 +- [x] イベント関連リクエスト +- [x] 実績関連リクエスト +- [x] 編成関連リクエスト +- [x] プレビュー関連リクエスト +- [x] 一部のショップ関連リクエスト +- [ ] 完全な戦闘フロー +- [ ] ミッション / クエスト進行 +- [ ] ガチャ / 募集システム +- [ ] 完全なショップ挙動 +- [ ] マルチプレイシステム +- [ ] 基地 / 宿舎システム +- [ ] クライアント API 全体の対応 + +## 貢献者 + +- [Naruse](https://github.com/DevilProMT) +- [Kei-Luna](https://github.com/Kei-Luna) + +## 利用上の注意 + +本ソフトウェアはローカル環境での研究・検証用途を想定しています。 +公式サービスへの不正な接続、妨害、または商用利用を意図したものではありません。 + +## 法的免責事項 + +MikuSBは教育および研究目的で開発されました。 + +- 元のゲーム及び関連フランチャイズに関するすべての商標、著作権知的財産権はそれぞれの所有者に帰属します。 +- このリポジトリには、著作権で保護されたゲームアセット、バイナリ、マスターデータは一切含まれていません。 +- 自己責任でご利用下さい。 著者は、本ソフトウェアによって生じるいかなる損害または法的結果についても一切責任を負いません。 + +本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 diff --git a/docs/user/README_zh.md b/docs/user/README_zh.md new file mode 100644 index 0000000..af0cc00 --- /dev/null +++ b/docs/user/README_zh.md @@ -0,0 +1,115 @@ +# MikuSB + +Languages: [English](../../README.md) | 中文 | [日本語](README_jp.md) + +MikuSB 是某款地牢题材动漫游戏的服务器模拟器。 +它会从一个 `net10.0` 应用中启动 `SdkServer`、`GameServer`,以及可选的本地 HTTP/HTTPS 代理。 + +[Discord](https://discord.gg/aMwCu9JyUR) + +## 文档 + +- [使用指导](usage/USAGE_zh.md) +- [命令使用指南](commands/COMMAND_GUIDE_zh.md) +- [命令目标说明](commands/COMMAND_TARGET_zh.md) +- [Linux 使用说明](platform/README_linux_zh.md) + +## 概览 + +- `SdkServer` + - 提供 HTTP API 并分发响应 + - 返回服务器列表、版本查询和各类兜底响应 +- `GameServer` + - 接受基于 TCP 的游戏连接 + - 处理 `ReqCallGS` 与部分普通协议包 +- `Proxy` + - 启用时监听 `127.0.0.1:8888` + - 将部分 Snowbreak 相关域名重定向到本地 `SdkServer` +- `Common` / `Proto` / `TcpSharp` + - 共享数据、protobuf 定义与网络通信基础设施 + +## 项目结构 + +- [MikuSB](../../MikuSB): 入口程序 +- [SdkServer](../../SdkServer): HTTP 服务与分发 +- [GameServer](../../GameServer): 主游戏服务器 +- [Proxy](../../Proxy): 本地代理 +- [Common](../../Common): 配置、数据库与公共工具 +- [Proto](../../Proto): protobuf 定义 + +## 环境要求 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) + +## 运行 + +1. 还原依赖并构建。 + +```powershell +dotnet build +``` + +2. 在 `Config/Config.json` 中将 `GamePath` 设置为游戏可执行文件路径。 +3. 启动服务。 + +```powershell +dotnet run --project .\MikuSB +``` + +4. 在服务端控制台创建账号。 +5. 在服务端控制台执行 `game` 命令。 +6. 启动游戏并登录。 + +发布命令与生成数据说明见[使用指导](usage/USAGE_zh.md)。 + +## 功能列表 + +- [x] 登录与基础账号进入 +- [x] 玩家数据加载 +- [x] 背包数据加载 +- [x] 角色数据加载 +- [x] 皮肤数据加载 +- [x] 武器数据加载 +- [x] 大厅展示角色切换 +- [x] 角色皮肤切换 +- [x] 角色皮肤形态切换 +- [x] 武器替换 +- [x] 武器强化 +- [x] 玩家改名 +- [x] 当前已支持大厅状态的基础保存 +- [x] 主线章节关卡进入及相关流程 +- [x] 日常关卡进入及相关流程 +- [x] 基础玩家设置同步 +- [x] 基础个人资料同步 +- [x] 活动相关请求 +- [x] 成就相关请求 +- [x] 编队相关请求 +- [x] 预览相关请求 +- [x] 部分商店相关请求 +- [ ] 完整战斗流程 +- [ ] 任务 / 委托进度 +- [ ] 抽卡 / 招募系统 +- [ ] 完整商店行为 +- [ ] 多人系统 +- [ ] 基地 / 宿舍系统 +- [ ] 客户端 API 全覆盖 + +## 贡献者 + +- [Naruse](https://github.com/DevilProMT) +- [Kei-Luna](https://github.com/Kei-Luna) + +## 使用说明 + +本软件仅用于本地环境下的研究与测试。 +不用于对官方服务进行未授权访问、干扰或商业用途。 + +## 法律免责声明 + +MikuSB 仅为教育与研究目的开发。 + +- 与原游戏及其相关系列有关的所有商标、版权及其他知识产权均归其各自所有者所有。 +- 本仓库不包含任何受版权保护的游戏资源、二进制文件或主数据。 +- 使用本软件需自行承担风险。作者不对因使用本软件导致的任何损失或法律后果负责。 + +若您是权利持有方并对本软件有任何顾虑,请在 Discord 联系 `devilpromt` 或 `kei_luna`。 diff --git a/docs/user/commands/COMMAND_GUIDE_en.md b/docs/user/commands/COMMAND_GUIDE_en.md new file mode 100644 index 0000000..a15d50c --- /dev/null +++ b/docs/user/commands/COMMAND_GUIDE_en.md @@ -0,0 +1,160 @@ +# MikuSB Command Guide (From Zero) + +Languages: English | [中文](COMMAND_GUIDE_zh.md) | [日本語](COMMAND_GUIDE_jp.md) + +> This guide explains how to use commands, especially `giveall`. + +## 1. Start the server + +Start the server first. Setup and run commands are covered in the [usage guide](../usage/USAGE_en.md). + +After startup, the console will show that you can type `help` for command help. + +--- + +## 2. Where to enter commands + +You can enter commands in two places: + +1. **Server console**: enter commands directly (no `/`) + - Example: `help` +2. **In-game chat**: prefix commands with `/` + - Example: `/help` + +--- + +## 3. Basic command syntax + +Command structure: + +```text +
@ +``` + +- `@` is optional and specifies the target player. +- Without `@`, the command applies to the sender by default. +- Target resolution details are covered in [command target notes](COMMAND_TARGET_en.md). + +Notes: + +- Options are passed as `p1 l90 g9 s9` (no leading `-`). +- `detail`/`guid` can be `-1` to apply to all. + +--- + +## 4. Learn `help` first + +```text +help +help giveall +help girl +help debug +``` + +--- + +## 5. Common commands (quick list) + +- `help [command]` (alias: `h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]` (alias: `dbg`) +- `girl add p l s` (alias: `g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall [options]` (alias: `ga`) + +--- + +## 6. How to use `giveall` (important) + +The main command `giveall` is also aliased as `ga`. +Available subcommands: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` +- `furniture` + +### 6.1 Parameter rules (important) + +In the current implementation, option parameters should be written as: + +- `p1` +- `l90` +- `g9` + +Do **not** write `-p1` / `-l90` / `-g9` or they will be parsed as other parameters. + +### 6.2 Common examples + +```text +# Give yourself all weapons, particular=1, level 90 +giveall weapon -1 p1 l90 + +# Give all weapons to UID=1 +giveall weapon -1 p1 l90 @1 + +# Give yourself all support cards +giveall card -1 p1 l80 + +# Give yourself all weapon skins +giveall weaponskin -1 p1 + +# Give yourself all character skins (genre=9 is just an example) +giveall skin -1 g9 p1 l1 +``` + +Notes: + +- `detail=-1` means “all” +- `detail>=0` means a specific item + +--- + +## 7. `girl` commands + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +--- + +## 8. Debug toggles + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 9. FAQ + +### Q1: “Command not found” + +- Use `help` to confirm the command exists +- Remember `/` in chat +- Do not use `/` in the server console + +### Q2: “Player not found” + +See [command target notes](COMMAND_TARGET_en.md). + +### Q3: Command ran but results look wrong + +- Check `help ` for the required parameters +- For `giveall`, use the `p1 l90 g9` style diff --git a/docs/user/commands/COMMAND_GUIDE_jp.md b/docs/user/commands/COMMAND_GUIDE_jp.md new file mode 100644 index 0000000..a0dd4d1 --- /dev/null +++ b/docs/user/commands/COMMAND_GUIDE_jp.md @@ -0,0 +1,158 @@ +# MikuSB コマンド使用ガイド(ゼロから) + +Languages: [English](COMMAND_GUIDE_en.md) | [中文](COMMAND_GUIDE_zh.md) | 日本語 + +> この文書では、特に `giveall` を中心にコマンドの使い方を説明します。 + +## 1. サーバーを起動する + +先にサーバーを起動してください。セットアップと起動コマンドは[使用ガイド](../usage/USAGE_jp.md)を参照してください。 + +起動後、コンソールで `help` を入力するとコマンドヘルプを確認できます。 + +--- + +## 2. コマンド入力場所 + +コマンドは次の 2 か所で入力できます。 + +1. **サーバーコンソール**: `/` なしで直接入力します。 + - 例: `help` +2. **ゲーム内チャット**: コマンドの前に `/` を付けます。 + - 例: `/help` + +--- + +## 3. 基本構文 + +```text +
@ +``` + +- `@` は任意で、対象プレイヤーを指定します。 +- `@` を省略すると、コマンド送信者が対象になります。 +- 対象解決の詳細は[コマンド対象説明](COMMAND_TARGET_jp.md)を参照してください。 + +補足: + +- オプションは `p1 l90 g9 s9` のように書きます(先頭に `-` は付けません)。 +- `detail` / `guid` は `-1` で全対象を表します。 + +--- + +## 4. まず `help` を確認する + +```text +help +help giveall +help girl +help debug +``` + +--- + +## 5. よく使うコマンド + +- `help [command]`(別名: `h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]`(別名: `dbg`) +- `girl add p l s`(別名: `g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall [options]`(別名: `ga`) + +--- + +## 6. `giveall` の使い方 + +`giveall` の別名は `ga` です。 +利用できるサブコマンド: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` +- `furniture` + +### 6.1 パラメーター規則 + +現在の実装では、オプションは次のように書きます。 + +- `p1` +- `l90` +- `g9` + +`-p1` / `-l90` / `-g9` のようには書かないでください。別の引数として解析されます。 + +### 6.2 例 + +```text +# 自分に全武器を付与、particular=1、レベル90 +giveall weapon -1 p1 l90 + +# UID=1 のプレイヤーに全武器を付与 +giveall weapon -1 p1 l90 @1 + +# 自分に全サポートカードを付与 +giveall card -1 p1 l80 + +# 自分に全武器スキンを付与 +giveall weaponskin -1 p1 + +# 自分に全キャラクタースキンを付与(genre=9 は例) +giveall skin -1 g9 p1 l1 +``` + +補足: + +- `detail=-1` は「全部」を意味します。 +- `detail>=0` は特定の項目を意味します。 + +--- + +## 7. `girl` コマンド + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +--- + +## 8. debug 切り替え + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 9. FAQ + +### Q1: “Command not found” + +- `help` でコマンドが存在するか確認します。 +- ゲーム内チャットでは `/` を付けます。 +- サーバーコンソールでは `/` を付けません。 + +### Q2: “Player not found” + +[コマンド対象説明](COMMAND_TARGET_jp.md)を参照してください。 + +### Q3: コマンドは実行されたが結果がおかしい + +- `help ` で必要な引数を確認します。 +- `giveall` のオプションは `p1 l90 g9` の形式で入力します。 diff --git a/docs/user/commands/COMMAND_GUIDE_zh.md b/docs/user/commands/COMMAND_GUIDE_zh.md new file mode 100644 index 0000000..1970b8b --- /dev/null +++ b/docs/user/commands/COMMAND_GUIDE_zh.md @@ -0,0 +1,160 @@ +# MikuSB 命令使用指南(从零开始) + +Languages: [English](COMMAND_GUIDE_en.md) | 中文 | [日本語](COMMAND_GUIDE_jp.md) + +> 这份文档专门讲「怎么用命令」,尤其是 `giveall`。 + +## 1. 先把服务跑起来 + +请先启动服务。安装与运行命令见[使用指导](../usage/USAGE_zh.md)。 + +启动成功后,控制台会提示可输入 `help` 获取命令帮助。 + +--- + +## 2. 命令在哪里输入 + +你可以在两个地方输入命令: + +1. **服务端控制台**:直接输入命令(不带 `/`) + - 例如:`help` +2. **游戏内聊天**:命令前带 `/` + - 例如:`/help` + +--- + +## 3. 命令基础语法 + +命令结构: + +```text +<主命令> <子命令> <参数> @<目标UID> +``` + +- `@<目标UID>` 可选,用于指定目标玩家。 +- 不写 `@` 时,默认对命令发送者生效。 +- 目标解析细节见[命令目标说明](COMMAND_TARGET_zh.md)。 + +说明: + +- 选项参数写成 `p1 l90 g9 s9`(不带 `-`)。 +- `detail` / `guid` 允许使用 `-1` 表示全部。 + +--- + +## 4. 先学会 help + +```text +help +help giveall +help girl +help debug +``` + +--- + +## 5. 常用命令速览 + +- `help [command]`(别名:`h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]`(别名:`dbg`) +- `girl add p l s`(别名:`g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall <类型> [选项]`(别名:`ga`) + +--- + +## 6. giveall 怎么用(重点) + +`giveall` 主命令别名是 `ga`。 +可用子命令: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` +- `furniture` + +### 6.1 参数规则(很关键) + +在当前实现里,选项参数建议写成: + +- `p1` +- `l90` +- `g9` + +即 **不要写成 `-p1` / `-l90` / `-g9`**,否则会被当成其他参数处理。 + +### 6.2 常见示例 + +```text +# 给自己所有武器,particular=1,等级90 +giveall weapon -1 p1 l90 + +# 给 UID=1 的玩家所有武器 +giveall weapon -1 p1 l90 @1 + +# 给自己所有支援卡 +giveall card -1 p1 l80 + +# 给自己所有武器皮肤 +giveall weaponskin -1 p1 + +# 给自己所有角色皮肤(genre=9 仅示例) +giveall skin -1 g9 p1 l1 +``` + +说明: + +- `detail=-1` 代表“全部” +- `detail>=0` 代表给某个具体条目 + +--- + +## 7. girl 命令 + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +--- + +## 8. debug 开关 + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 9. 常见问题 + +### Q1:提示“未找到命令” + +- 用 `help` 看命令是否存在 +- 游戏聊天里记得加 `/` +- 控制台里不要加 `/` + +### Q2:提示“未找到玩家” + +见[命令目标说明](COMMAND_TARGET_zh.md)。 + +### Q3:命令执行了但结果不对 + +- 优先用 `help <命令>` 对照参数格式 +- `giveall` 选项参数按 `p1 l90 g9` 这种写法输入 diff --git a/docs/user/commands/COMMAND_TARGET_en.md b/docs/user/commands/COMMAND_TARGET_en.md new file mode 100644 index 0000000..52931fa --- /dev/null +++ b/docs/user/commands/COMMAND_TARGET_en.md @@ -0,0 +1,60 @@ +# Command targets and `giveall` parameters (EN) + +Languages: English | [中文](COMMAND_TARGET_zh.md) | [日本語](COMMAND_TARGET_jp.md) + +This document explains why running `giveall` in the console can show “player not found”, and what ID you should provide. + +## 1. Target resolution rules + +The command system resolves targets as follows: + +- If no target is provided, the default target is the **command sender**. +- When running in the console, the sender is `Console` with UID `0`. +- Target syntax is: `@` (for example, `@1001`). +- The current implementation only parses numeric UIDs after `@`; it does not accept usernames. + +## 2. Why “player not found” happens + +`giveall` checks whether the target is online (`CheckOnlineTarget()`): + +- The target must be an **online connected player**. +- If the target is the console (`uid=0`) or offline, it will report “player not found”. + +## 3. `giveall` parameter meaning (important) + +Using `weapon` as an example: + +```text +/giveall weapon p l @ +``` + +- ``: item detail, `-1` means all. +- `p`: item particular parameter (for example `p1`). +- `l`: level parameter. +- `@`: target player UID. + +Notes: + +- Strings like `miku` are not treated as target usernames. +- `p1` is an item parameter, not a player ID. + +## 4. UID or username? + +Conclusion: + +- Use **UID** as the target parameter (`@`). +- Do not use usernames. + +Database mapping: + +- Account table: `Account` (`[SugarTable("Account")]`) +- Player ID field: `Uid` (primary key) +- Username field: `Username` + +## 5. Correct example + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +Meaning: give all weapons to the online player with UID `1001` (particular=1, level=90). diff --git a/docs/user/commands/COMMAND_TARGET_jp.md b/docs/user/commands/COMMAND_TARGET_jp.md new file mode 100644 index 0000000..b80f24f --- /dev/null +++ b/docs/user/commands/COMMAND_TARGET_jp.md @@ -0,0 +1,60 @@ +# コマンド対象と `giveall` パラメーター説明(日本語) + +Languages: [English](COMMAND_TARGET_en.md) | [中文](COMMAND_TARGET_zh.md) | 日本語 + +この文書では、コンソールで `giveall` を実行したときに “player not found” が出る理由と、どの ID を指定するべきかを説明します。 + +## 1. 対象解決ルール + +コマンドシステムは次のように対象を解決します。 + +- 対象を指定しない場合、既定の対象は**コマンド送信者**です。 +- コンソールで実行した場合、送信者は `Console` で UID は `0` です。 +- 対象指定の構文は `@` です(例: `@1001`)。 +- 現在の実装では `@` の後ろの数値 UID のみを解析し、ユーザー名は対象として扱いません。 + +## 2. “player not found” が出る理由 + +`giveall` は実行前に対象がオンラインか確認します。 + +- 対象は**オンライン接続中のプレイヤー**である必要があります。 +- 対象がコンソール(`uid=0`)またはオフラインの場合、“player not found” が表示されます。 + +## 3. `giveall` パラメーターの意味 + +`weapon` を例にします。 + +```text +/giveall weapon p l @ +``` + +- ``: アイテム detail。`-1` は全部を意味します。 +- `p`: アイテム particular パラメーター(例: `p1`)。 +- `l`: レベルパラメーター。 +- `@`: 対象プレイヤー UID。 + +注意: + +- `miku` のような文字列は対象ユーザー名として扱われません。 +- `p1` はアイテムパラメーターであり、プレイヤー ID ではありません。 + +## 4. UID かユーザー名か + +結論: + +- 対象パラメーターには **UID** を指定してください(`@`)。 +- ユーザー名は指定しません。 + +データベース上の対応: + +- アカウントテーブル: `Account`(`[SugarTable("Account")]`) +- プレイヤー ID フィールド: `Uid`(主キー) +- ユーザー名フィールド: `Username` + +## 5. 正しい例 + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +意味: UID `1001` のオンラインプレイヤーに全武器を付与します(particular=1、level=90)。 diff --git a/docs/user/commands/COMMAND_TARGET_zh.md b/docs/user/commands/COMMAND_TARGET_zh.md new file mode 100644 index 0000000..9544c54 --- /dev/null +++ b/docs/user/commands/COMMAND_TARGET_zh.md @@ -0,0 +1,60 @@ +# 命令目标与 `giveall` 参数说明(中文) + +Languages: [English](COMMAND_TARGET_en.md) | 中文 | [日本語](COMMAND_TARGET_jp.md) + +本文档说明为什么在控制台执行 `giveall` 时会出现“未找到玩家”,以及“ID 应该填什么”。 + +## 1. 目标解析规则 + +命令系统会按如下方式解析目标: + +- 不写目标时,默认目标是**命令发送者本人**。 +- 在控制台执行命令时,发送者是 `Console`,其 UID 为 `0`。 +- 指定目标的语法是:`@`(例如 `@1001`)。 +- 当前实现只解析 `@` 后面的**数字 UID**,不支持直接写用户名作为目标。 + +## 2. 为什么会“未找到玩家” + +`giveall` 在执行前会检查目标是否在线(`CheckOnlineTarget()`): + +- 目标必须是**在线连接中的玩家**。 +- 目标是控制台(`uid=0`)或离线玩家时,会提示“未找到玩家”。 + +## 3. `giveall` 参数含义(重点) + +以 `weapon` 为例: + +```text +/giveall weapon p l @ +``` + +- ``:物品 detail,`-1` 表示全部。 +- `p`:物品 particular 参数(例如 `p1`)。 +- `l`:等级参数。 +- `@`:目标玩家 UID。 + +注意: + +- `miku` 这种字符串不会被当成目标玩家名。 +- `p1` 是物品参数,不是玩家 ID。 + +## 4. 到底该填 UID 还是用户名? + +结论: + +- 目标参数请填 **UID**(`@`)。 +- 不是用户名。 + +数据库对应关系: + +- 账号表:`Account`(`[SugarTable("Account")]`) +- 玩家 ID 字段:`Uid`(主键) +- 用户名字段:`Username` + +## 5. 正确示例 + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +表示:给 UID 为 `1001` 的在线玩家发放全部武器(particular=1,level=90)。 diff --git a/docs/user/platform/README_linux_en.md b/docs/user/platform/README_linux_en.md new file mode 100644 index 0000000..67a6fe9 --- /dev/null +++ b/docs/user/platform/README_linux_en.md @@ -0,0 +1,41 @@ +# MikuSB on Linux + +Languages: English | [中文](README_linux_zh.md) | [日本語](README_linux_jp.md) + +## Config + +### Steam Launch Options + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### Start Local Server + +```bash +./MikuSB +``` + +For build and publish commands, see the [usage guide](../usage/USAGE_en.md). + +### Root CA Bundle + +The root CA cert should be in `proxy-certs/MikuSB.Proxy.Root.pem`. + +### Proton/Wine Root CA + +This step may not be required again if the Proton PFX (Wine prefix) folder is recreated. + +`Proton Hotfix` is the Proton version selected in Steam `Force the use of a specific Steam Play compatibility tool`. + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### Start The Game + +## TODO + +- [ ] Tool/script for CA cert creation and Proton/Wine installation +- [ ] Automatically handle the setup in the main program diff --git a/docs/user/platform/README_linux_jp.md b/docs/user/platform/README_linux_jp.md new file mode 100644 index 0000000..1a4eae5 --- /dev/null +++ b/docs/user/platform/README_linux_jp.md @@ -0,0 +1,41 @@ +# MikuSB on Linux + +Languages: [English](README_linux_en.md) | [中文](README_linux_zh.md) | 日本語 + +## 設定 + +### Steam 起動オプション + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### ローカルサーバーを起動する + +```bash +./MikuSB +``` + +ビルドと公開コマンドは[使用ガイド](../usage/USAGE_jp.md)を参照してください。 + +### ルート CA バンドル + +ルート CA 証明書のパスは `proxy-certs/MikuSB.Proxy.Root.pem` です。 + +### Proton/Wine ルート CA + +Proton PFX(Wine prefix)フォルダーを再作成した場合でも、この手順が再度必要にならないことがあります。 + +`Proton Hotfix` は Steam の `Force the use of a specific Steam Play compatibility tool` で選択した Proton バージョンです。 + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### ゲームを起動する + +## TODO + +- [ ] CA 証明書を作成し Proton/Wine に導入するツールまたはスクリプト +- [ ] メインプログラムで自動処理する diff --git a/docs/user/platform/README_linux_zh.md b/docs/user/platform/README_linux_zh.md new file mode 100644 index 0000000..1b2ba05 --- /dev/null +++ b/docs/user/platform/README_linux_zh.md @@ -0,0 +1,41 @@ +# MikuSB 在 Linux 上使用 + +Languages: [English](README_linux_en.md) | 中文 | [日本語](README_linux_jp.md) + +## 配置 + +### Steam 启动项 + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### 启动本地服务器 + +```bash +./MikuSB +``` + +构建与发布命令见[使用指导](../usage/USAGE_zh.md)。 + +### 根证书包 + +根证书路径:`proxy-certs/MikuSB.Proxy.Root.pem` + +### Proton/Wine 根证书 + +注意:即使删除 Proton PFX(Wine 前缀)目录,不重新执行这一步也可能不报证书问题。 + +`Proton Hotfix` 是 Steam 中选择的 `Force the use of a specific Steam Play compatibility tool` 对应版本。 + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### 启动游戏 + +## TODO + +- [ ] 提供自动生成并安装 CA 证书的工具/脚本 +- [ ] 在主程序中自动完成上述步骤 diff --git a/docs/user/usage/USAGE_en.md b/docs/user/usage/USAGE_en.md new file mode 100644 index 0000000..365703a --- /dev/null +++ b/docs/user/usage/USAGE_en.md @@ -0,0 +1,184 @@ +# MikuSB Usage Guide (From Zero) + +Languages: English | [中文](USAGE_zh.md) | [日本語](USAGE_jp.md) + +> This document focuses on: full command flow, database fields and their sources, and how data is generated. + +## 1. Run from scratch (development) + +### 1.1 Requirements + +- Install [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- Install Git + +### 1.2 Get the source + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 Build + +```bash +dotnet build +``` + +### 1.4 Start the server + +```bash +dotnet run --project ./MikuSB +``` + +After startup, the following services run together: + +- `SdkServer` (HTTP) +- `GameServer` (TCP) +- Local proxy (enabled by default, listens on `127.0.0.1:8888`) + +### 1.5 What is created on first start + +- `Config/Config.json` (generated if missing) +- `Config/Database/Miku.db` (SQLite database file) +- Database tables (Code First auto-create) +- `proxy-certs/*` (proxy root CA and derived certificates) +- `Config/Handbook/*` (command handbook text generated from TextMap) + +## 2. Publish commands + +### 2.1 Linux single-file publish + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.2 Windows publish (multi-file, same as CI) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. How resources and database are generated + +### 3.1 Resource files + +- On startup, the server checks key files under `Resources/` (for example `item/templates/card.json`, `item/templates/weapon.json`). +- If missing, it downloads the resource archive and extracts it into `Resources/`. +- The resource data is then loaded into in-memory `GameData.*` dictionaries/lists for later initialization of characters, weapons, and items. + +### 3.2 Database and table creation + +- Database type: SQLite (SqlSugar) +- Database path: `Config/Database/Miku.db` (can be changed in `Config/Config.json`) +- Table creation: scans all types that inherit `BaseDatabaseDataHelper` and runs Code First table creation + +### 3.3 How data is written + +- Player heartbeats enqueue UIDs for saving; by default it flushes every 5 minutes +- On process exit, a final flush is triggered + +## 4. Database tables and fields (with sources) + +> Primary key is unified as `Uid` (player UID). + +### 4.1 `Account` + +Fields: + +- `Uid`: account UID. If missing on first login it is created, starting from 1. +- `Username`: username. Default for auto-created account is `"MIKU"`. +- `Password`: password hash (SHA256); empty password stores an empty string. +- `BanType`: ban type enum. +- `Phone`: phone field (default `"123456"`). +- `Permissions` (JSON): permission list from `Config.json -> ServerOption.DefaultPermissions`. +- `ComboToken` / `DispatchToken`: session tokens written when generated. + +Source: + +- Auto-created: first login creates UID=1 if missing. +- Also manageable via logic/commands. + +### 4.2 `Player` + +Fields: + +- `Uid`: player UID (same as account). +- `Name`: display name, defaulted from account name (fallback to `Miku` when blank). +- `Signature`: signature (default `MikuPS`). +- `Level` / `Exp` / `Vigor` / `Gender`: base player attributes. +- `RegisterTime`: registration time (Unix seconds). +- `LastActiveTime`: last active time (refreshed on player manager init). +- `Attrs` (JSON): numeric attributes (tutorial values, currency, unlocks, etc.). +- `StrAttrs` (JSON): string attributes. +- `ShowItems` (JSON): profile display items. + +Source: + +- If account exists but player data does not, `PlayerGameData` is created. +- `Attrs` are filled/raised by tutorial and stage data during serialization. + +### 4.3 `inventory_data` + +Fields: + +- `Uid`: player UID. +- `NextUniqueUid`: inventory unique item ID allocator (starts at `100000`). +- `Items` (JSON): item dictionary (supplies, AR, manifestation, etc.). +- `Weapons` (JSON): weapon dictionary. +- `Skins` (JSON): skin dictionary. +- `SupportCards` (JSON): support card dictionary. +- `SkinTypesBySkinId` (JSON): skin form mapping (`nSkinId -> nType`). + +Source: + +- On first player creation, initial skins/characters/supplies are granted from resource tables. +- Business requests (upgrade, replace, skin change, etc.) continually modify this table. + +### 4.4 `character_data` + +Fields: + +- `Uid`: player UID. +- `Characters` (JSON): character list. + - Key subfields: `Guid`, `TemplateId`, `Level`, `Break`, `Evolue`, `ProLevel`, `Trust`, `WeaponUniqueId`, `SkinId`, `WeaponSkinId`, `SupportSlots`, `UnlockedSkin`, `Spines`, `Affixs`, etc. +- `NextCharacterGuid`: character GUID counter. + +Source: + +- First-time player initialization creates characters from resource templates. +- Character creation automatically assigns default weapon/skin bindings. + +### 4.5 `lineup_data` + +Fields: + +- `Uid`: player UID. +- `LineupInfo` (JSON): lineup dictionary keyed by lineup slot. + - Subfields: `Index`, `Name`, `Member1`, `Member2`, `Member3`. + +Source: + +- New player initialization randomly picks 3 characters for the default lineup. +- Later lineup updates keep writing to this table. + +## 5. Quick checks + +### 5.1 Inspect database files and tables + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +### 5.2 Check database path in config + +```bash +cat ./Config/Config.json +``` + +## 6. Notes + +- This project stores many fields as JSON columns; read the corresponding C# data structures when needed. +- To reset local progress, stop the server, then back up or delete `Config/Database/Miku.db` and restart. diff --git a/docs/user/usage/USAGE_jp.md b/docs/user/usage/USAGE_jp.md new file mode 100644 index 0000000..f840dec --- /dev/null +++ b/docs/user/usage/USAGE_jp.md @@ -0,0 +1,127 @@ +# MikuSB 使用ガイド(ゼロから) + +Languages: [English](USAGE_en.md) | [中文](USAGE_zh.md) | 日本語 + +> この文書では、起動手順、公開コマンド、データベース項目、データ生成の流れを扱います。 + +## 1. ゼロから起動する(開発環境) + +### 1.1 要件 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) をインストールする +- Git をインストールする + +### 1.2 ソースコードを取得する + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 ビルド + +```bash +dotnet build +``` + +### 1.4 サーバーを起動する + +```bash +dotnet run --project ./MikuSB +``` + +起動後、次のサービスが同時に動作します。 + +- `SdkServer`(HTTP) +- `GameServer`(TCP) +- ローカルプロキシ(既定で有効、`127.0.0.1:8888` で待ち受け) + +### 1.5 初回起動時に作成されるもの + +- `Config/Config.json`(存在しない場合に生成) +- `Config/Database/Miku.db`(SQLite データベース) +- データベーステーブル(Code First で自動作成) +- `proxy-certs/*`(プロキシ用ルート CA と派生証明書) +- `Config/Handbook/*`(TextMap から生成されるコマンドハンドブック) + +## 2. 公開コマンド + +### 2.1 Linux 単一ファイル公開 + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.2 Windows 公開(複数ファイル、CI と同等) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. リソースとデータベースの生成 + +### 3.1 リソースファイル + +- 起動時に `Resources/` 配下の主要ファイルを確認します。 +- 不足している場合はリソースアーカイブをダウンロードして展開します。 +- 読み込まれたデータは、キャラクター、武器、アイテムの初期化に使われます。 + +### 3.2 データベースとテーブル作成 + +- データベース種類: SQLite(SqlSugar) +- データベースパス: `Config/Database/Miku.db`(`Config/Config.json` で変更可能) +- テーブル作成: `BaseDatabaseDataHelper` を継承する型を走査し、Code First で作成します。 + +### 3.3 データの保存 + +- プレイヤーのハートビートで UID が保存キューに入ります。 +- 既定では 5 分ごとに保存されます。 +- プロセス終了時に最後の保存が実行されます。 + +## 4. 主なデータベーステーブル + +### 4.1 `Account` + +- `Uid`: アカウント UID +- `Username`: ユーザー名 +- `Password`: パスワードハッシュ +- `Permissions`: 権限リスト +- `ComboToken` / `DispatchToken`: セッショントークン + +### 4.2 `Player` + +- `Uid`: プレイヤー UID +- `Name`: 表示名 +- `Level` / `Exp` / `Vigor` / `Gender`: 基本属性 +- `Attrs` / `StrAttrs`: 属性データ +- `ShowItems`: プロフィール表示項目 + +### 4.3 `inventory_data` + +- `Items`: アイテム辞書 +- `Weapons`: 武器辞書 +- `Skins`: スキン辞書 +- `SupportCards`: サポートカード辞書 + +### 4.4 `character_data` + +- `Characters`: キャラクター一覧 +- `NextCharacterGuid`: キャラクター GUID カウンター + +### 4.5 `lineup_data` + +- `LineupInfo`: 編成情報 + +## 5. 確認コマンド + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +## 6. 注意 + +- 多くの項目は JSON カラムとして保存されます。 +- ローカル進行状況をリセットする場合は、サーバーを停止してから `Config/Database/Miku.db` をバックアップまたは削除してください。 diff --git a/docs/user/usage/USAGE_zh.md b/docs/user/usage/USAGE_zh.md new file mode 100644 index 0000000..1f4da76 --- /dev/null +++ b/docs/user/usage/USAGE_zh.md @@ -0,0 +1,184 @@ +# MikuSB 使用指导(从零开始) + +Languages: [English](USAGE_en.md) | 中文 | [日本語](USAGE_jp.md) + +> 本文档聚焦:完整命令流程、数据库字段含义与来源、数据如何生成。 + +## 1. 从零开始运行(开发模式) + +### 1.1 环境准备 + +- 安装 [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) +- 安装 Git + +### 1.2 获取源码 + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 构建 + +```bash +dotnet build +``` + +### 1.4 启动服务 + +```bash +dotnet run --project ./MikuSB +``` + +启动后会同时拉起: + +- `SdkServer`(HTTP) +- `GameServer`(TCP) +- 本地代理(默认启用,监听 `127.0.0.1:8888`) + +### 1.5 首次启动会自动生成的内容 + +- `Config/Config.json`(若不存在则生成默认配置) +- `Config/Database/Miku.db`(SQLite 数据库文件) +- 数据库表结构(Code First 自动建表) +- `proxy-certs/*`(代理根证书与派生证书) +- `Config/Handbook/*`(命令手册文本,按 TextMap 生成) + +## 2. 发布命令 + +### 2.1 Linux 发布单文件 + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.2 Windows 发布(多文件,和 CI 一致) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. 资源与数据库“如何生成” + +### 3.1 资源文件来源 + +- 服务启动时会检查 `Resources/` 下的关键文件(如 `item/templates/card.json`、`item/templates/weapon.json`)。 +- 若缺失,会自动下载资源压缩包并解压到 `Resources/`。 +- 资源数据随后被加载为内存中的 `GameData.*` 字典/列表,用于后续角色、武器、道具初始化。 + +### 3.2 数据库与表“如何生成” + +- 数据库类型:SQLite(SqlSugar) +- 数据库路径:`Config/Database/Miku.db`(可通过 `Config/Config.json` 修改目录与文件名) +- 建表方式:扫描所有继承 `BaseDatabaseDataHelper` 的类型并执行 Code First 自动建表 + +### 3.3 数据“如何写入” + +- 玩家心跳会将 UID 加入待保存列表,默认每 5 分钟批量落盘 +- 进程退出时会触发最终一次保存(final flush) + +## 4. 数据库表与字段说明(含来源) + +> 主键统一为 `Uid`(玩家 UID)。 + +### 4.1 `Account` + +字段: + +- `Uid`:账号 UID。首次登录时若不存在账号会创建,默认从 1 开始递增。 +- `Username`:用户名。首次自动创建账号时默认 `"MIKU"`。 +- `Password`:密码哈希(SHA256);空密码会存空字符串。 +- `BanType`:封禁类型枚举。 +- `Phone`:手机号字段(默认 `"123456"`)。 +- `Permissions`(JSON):权限列表,来源于 `Config.json -> ServerOption.DefaultPermissions`。 +- `ComboToken` / `DispatchToken`:会话 token,调用对应生成方法时写入。 + +来源: + +- 自动创建:首次处理登录包时,若 UID=1 不存在则创建。 +- 也可通过逻辑/命令触发账号管理。 + +### 4.2 `Player` + +字段: + +- `Uid`:玩家 UID(与账号一致)。 +- `Name`:显示名,默认取账号名并标准化(空白时回退 `Miku`)。 +- `Signature`:签名(默认 `MikuPS`)。 +- `Level` / `Exp` / `Vigor` / `Gender`:玩家基础属性。 +- `RegisterTime`:注册时间(Unix 秒,创建对象时写入)。 +- `LastActiveTime`:最近活跃时间(初始化玩家管理器时刷新)。 +- `Attrs`(JSON):数值属性(大量引导/货币/关卡解锁等引导值)。 +- `StrAttrs`(JSON):字符串属性。 +- `ShowItems`(JSON):个人展示道具列表。 + +来源: + +- 当账号已存在但无玩家数据时,创建 `PlayerGameData`。 +- `Attrs` 会在玩家序列化流程中由引导与关卡数据补齐/抬高。 + +### 4.3 `inventory_data` + +字段: + +- `Uid`:玩家 UID。 +- `NextUniqueUid`:背包唯一物品 ID 分配器(默认从 `100000` 开始)。 +- `Items`(JSON):普通道具字典(包含补给、AR、彰痕等)。 +- `Weapons`(JSON):武器字典。 +- `Skins`(JSON):皮肤字典。 +- `SupportCards`(JSON):支援卡字典。 +- `SkinTypesBySkinId`(JSON):皮肤形态映射(`nSkinId -> nType`)。 + +来源: + +- 首次创建玩家后,系统会根据资源表批量发放初始皮肤、角色、补给等。 +- 各种业务请求(强化、替换、皮肤切换等)持续修改该表。 + +### 4.4 `character_data` + +字段: + +- `Uid`:玩家 UID。 +- `Characters`(JSON):角色列表。 + - 关键子字段:`Guid`、`TemplateId`、`Level`、`Break`、`Evolue`、`ProLevel`、`Trust`、`WeaponUniqueId`、`SkinId`、`WeaponSkinId`、`SupportSlots`、`UnlockedSkin`、`Spines`、`Affixs` 等。 +- `NextCharacterGuid`:角色 GUID 递增计数器。 + +来源: + +- 首次玩家初始化会按资源中的角色模板批量创建角色。 +- 创建角色时会自动补默认武器与皮肤关联。 + +### 4.5 `lineup_data` + +字段: + +- `Uid`:玩家 UID。 +- `LineupInfo`(JSON):编队字典,键为编队位。 + - 子字段:`Index`、`Name`、`Member1`、`Member2`、`Member3`。 + +来源: + +- 新玩家初始化后,会随机选 3 名角色写入默认编队。 +- 后续编队更新请求持续写入。 + +## 5. 快速排查与校验 + +### 5.1 查看数据库文件与表 + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +### 5.2 查看配置中的数据库路径 + +```bash +cat ./Config/Config.json +``` + +## 6. 备注 + +- 本项目大量字段为 JSON 列(对象序列化存储),阅读时建议结合对应 C# 数据结构一起看。 +- 若要重置本地进度,先停止服务,再备份或删除 `Config/Database/Miku.db` 后重新启动。 diff --git a/version.txt b/version.txt index 9fd0a3d..28b8e5b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=3.2 \ No newline at end of file +v=4.5 \ No newline at end of file