diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3d4d3e3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,125 @@ +[*.cs] + +# IDE0022: 使用方法的程序块主体 +csharp_style_expression_bodied_methods = false:silent + +[*.cs] +#### 命名样式 #### + +# 命名规则 + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# 符号规范 + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# 命名样式 + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +csharp_using_directive_placement = outside_namespace:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent +csharp_style_var_elsewhere = false:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_space_around_binary_operators = before_and_after +csharp_indent_labels = one_less_than_current + +[*.vb] +#### 命名样式 #### + +# 命名规则 + +dotnet_naming_rule.interface_should_be_以_i_开始.severity = suggestion +dotnet_naming_rule.interface_should_be_以_i_开始.symbols = interface +dotnet_naming_rule.interface_should_be_以_i_开始.style = 以_i_开始 + +dotnet_naming_rule.类型_should_be_帕斯卡拼写法.severity = suggestion +dotnet_naming_rule.类型_should_be_帕斯卡拼写法.symbols = 类型 +dotnet_naming_rule.类型_should_be_帕斯卡拼写法.style = 帕斯卡拼写法 + +dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.severity = suggestion +dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.symbols = 非字段成员 +dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.style = 帕斯卡拼写法 + +# 符号规范 + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.类型.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.类型.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.类型.required_modifiers = + +dotnet_naming_symbols.非字段成员.applicable_kinds = property, event, method +dotnet_naming_symbols.非字段成员.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.非字段成员.required_modifiers = + +# 命名样式 + +dotnet_naming_style.以_i_开始.required_prefix = I +dotnet_naming_style.以_i_开始.required_suffix = +dotnet_naming_style.以_i_开始.word_separator = +dotnet_naming_style.以_i_开始.capitalization = pascal_case + +dotnet_naming_style.帕斯卡拼写法.required_prefix = +dotnet_naming_style.帕斯卡拼写法.required_suffix = +dotnet_naming_style.帕斯卡拼写法.word_separator = +dotnet_naming_style.帕斯卡拼写法.capitalization = pascal_case + +dotnet_naming_style.帕斯卡拼写法.required_prefix = +dotnet_naming_style.帕斯卡拼写法.required_suffix = +dotnet_naming_style.帕斯卡拼写法.word_separator = +dotnet_naming_style.帕斯卡拼写法.capitalization = pascal_case + +[*.{cs,vb}] +end_of_line = crlf +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +tab_width = 4 +indent_size = 4 +dotnet_style_operator_placement_when_wrapping = beginning_of_line \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4207fbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,370 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +.idea/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +[Ll]aunchSettings.json +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +# *.pubxml +# *.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# Special Files +/SdkServer/Properties +/GameServer/OriginalProto +*.rar \ No newline at end of file diff --git a/Common/Common.csproj b/Common/Common.csproj new file mode 100644 index 0000000..024f2ef --- /dev/null +++ b/Common/Common.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + MikuSB + MikuCommon + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Common/Configuration/ConfigContainer.cs b/Common/Configuration/ConfigContainer.cs new file mode 100644 index 0000000..5472e47 --- /dev/null +++ b/Common/Configuration/ConfigContainer.cs @@ -0,0 +1,76 @@ +namespace MikuSB.Configuration; + +public class ConfigContainer +{ + public HttpServerConfig HttpServer { get; set; } = new(); + public GameServerConfig GameServer { get; set; } = new(); + public PathConfig Path { get; set; } = new(); + public ServerOption ServerOption { get; set; } = new(); +} + +public class HttpServerConfig +{ + public string BindAddress { get; set; } = "0.0.0.0"; + public string PublicAddress { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 21500; + + public string GetDisplayAddress() + { + return "http" + "://" + PublicAddress + ":" + Port; + } + + public string GetBindDisplayAddress() + { + return "http" + "://" + BindAddress + ":" + Port; + } +} + +public class GameServerConfig +{ + public string BindAddress { get; set; } = "0.0.0.0"; + public string PublicAddress { get; set; } = "127.0.0.1"; + public int Port { get; set; } = 21000; + public int KcpAliveMs { get; set; } = 45000; + public string DatabaseName { get; set; } = "Miku.db"; + public string GameServerId { get; set; } = "MikuSB"; + public string GameServerName { get; set; } = "MikuSB"; + public string GetDisplayAddress() + { + return PublicAddress + ":" + Port; + } +} + +public class PathConfig +{ + public string ResourcePath { get; set; } = "Resources"; + public string ConfigPath { get; set; } = "Config"; + public string DatabasePath { get; set; } = "Config/Database"; + public string HandbookPath { get; set; } = "Config/Handbook"; + public string LogPath { get; set; } = "Config/Logs"; + public string DataPath { get; set; } = "Config/Data"; +} + +public class ServerOption +{ + public string Language { get; set; } = "EN"; + public string FallbackLanguage { get; set; } = "EN"; + public string[] DefaultPermissions { get; set; } = ["Admin"]; + public ServerProfile ServerProfile { get; set; } = new(); + public bool AutoCreateUser { get; set; } = true; + public bool SavePersonalDebugFile { get; set; } = false; + public bool AutoSendResponseWhenNoHandler { get; set; } = true; +#if DEBUG + public bool EnableDebug { get; set; } = true; +#else + public bool EnableDebug { get; set; } = false; +#endif + public bool DebugMessage { get; set; } = true; + public bool DebugDetailMessage { get; set; } = true; + public bool DebugNoHandlerPacket { get; set; } = true; +} + +public class ServerProfile +{ + public string Name { get; set; } = "Miku-chan"; + public int Uid { get; set; } = 80; +} \ No newline at end of file diff --git a/Common/Configuration/HotfixContainer.cs b/Common/Configuration/HotfixContainer.cs new file mode 100644 index 0000000..742f10c --- /dev/null +++ b/Common/Configuration/HotfixContainer.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace MikuSB.Configuration; + +public class HotfixContainer +{ + public bool UseLocalCache { get; set; } = false; + public Dictionary Hotfixes { get; set; } = new(); + public Dictionary AesKeys { get; set; } = new (); + + public static string ExtractVersionNumber(string? version) + { + try + { + return version == null ? "" : version[..version.IndexOf('_')]; + } + catch + { + return ""; + } + } +} + +public class HotfixManfiset +{ + [JsonPropertyName("Asb")] public AsbData Asb { get; set; } = new(); + [JsonPropertyName("AsbPreDownload")] public AsbPreDownloadData AsbPreDownload { get; set; } = new(); + [JsonPropertyName("Audio")] public AudioData Audio { get; set; } = new(); + [JsonPropertyName("AudioPreDownload")] public AudioPreDownloadData AudioPreDownload { get; set; } = new(); + [JsonPropertyName("VideoEncrypt")] public VideoEncryptData VideoEncrypt { get; set; } = new(); +} + +public class AsbData +{ + [JsonPropertyName("android")] public PlatformInfo Android { get; set; } = new(); + [JsonPropertyName("iphone")] public PlatformInfo Iphone { get; set; } = new(); + [JsonPropertyName("pc")] public PlatformInfo Pc { get; set; } = new(); +} + +public class AsbPreDownloadData +{ + [JsonPropertyName("android")] public PlatformEncryptedInfo Android { get; set; } = new(); + [JsonPropertyName("iphone")] public PlatformEncryptedInfo Iphone { get; set; } = new(); +} + +public class AudioData +{ + [JsonPropertyName("platform")] public Dictionary Platform { get; set; } = new(); + [JsonPropertyName("revision")] public int Revision { get; set; } +} + +public class AudioPreDownloadData +{ + [JsonPropertyName("enable_time")] public long EnableTime { get; set; } + [JsonPropertyName("platform")] public Dictionary Platform { get; set; } = new(); + [JsonPropertyName("revision")] public int Revision { get; set; } +} + +public class VideoEncryptData +{ + [JsonPropertyName("filename")] public string FileName { get; set; } = ""; +} + +public class PlatformInfo +{ + [JsonPropertyName("enable_time")] public long EnableTime { get; set; } + [JsonPropertyName("revision")] public string Revision { get; set; } = ""; + [JsonPropertyName("suffix")] public string Suffix { get; set; } = ""; +} + +public class PlatformEncryptedInfo : PlatformInfo +{ + [JsonPropertyName("encrypt_key")] public string EncryptKey { get; set; } = ""; +} \ No newline at end of file diff --git a/Common/Data/Config/DictionaryConverter.cs b/Common/Data/Config/DictionaryConverter.cs new file mode 100644 index 0000000..cccb4e4 --- /dev/null +++ b/Common/Data/Config/DictionaryConverter.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; + +namespace MikuSB.Data.Config; + +class IntDictionaryConverter : JsonConverter> +{ + public override Dictionary? ReadJson(JsonReader reader, Type objectType, Dictionary? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.StartArray) + { + JArray.Load(reader); + return new Dictionary(); + } + else if (reader.TokenType == JsonToken.StartObject) + { + var obj = JObject.Load(reader); + var dict = new Dictionary(); + + foreach (var prop in obj.Properties()) + { + if (int.TryParse(prop.Name, out var key)) + { + dict[key] = prop.Value.ToObject(); + } + } + return dict; + } + + return new Dictionary(); + } + + public override void WriteJson(JsonWriter writer, Dictionary? value, JsonSerializer serializer) + { + writer.WriteStartObject(); + foreach (var kv in value) + { + writer.WritePropertyName(kv.Key.ToString()); + writer.WriteValue(kv.Value); + } + writer.WriteEndObject(); + } +} diff --git a/Common/Data/ExcelResource.cs b/Common/Data/ExcelResource.cs new file mode 100644 index 0000000..506a51e --- /dev/null +++ b/Common/Data/ExcelResource.cs @@ -0,0 +1,18 @@ +namespace MikuSB.Data; + +public abstract class ExcelResource +{ + public abstract uint GetId(); + + public virtual void Loaded() + { + } + + public virtual void Finalized() + { + } + + public virtual void AfterAllDone() + { + } +} \ No newline at end of file diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs new file mode 100644 index 0000000..a0fc47d --- /dev/null +++ b/Common/Data/GameData.cs @@ -0,0 +1,5 @@ +namespace MikuSB.Data; + +public static class GameData +{ +} \ No newline at end of file diff --git a/Common/Data/ResourceEntity.cs b/Common/Data/ResourceEntity.cs new file mode 100644 index 0000000..6ad6c87 --- /dev/null +++ b/Common/Data/ResourceEntity.cs @@ -0,0 +1,33 @@ +namespace MikuSB.Data; + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class ResourceEntity : Attribute +{ + [Obsolete("No effect")] + public ResourceEntity(string fileName, bool isCritical = false, bool isMultifile = false) + { + if (isMultifile) + FileName = new List(fileName.Split(',')); + else + FileName = [fileName]; + IsCritical = isCritical; + } + + + public ResourceEntity(string fileName, bool isMultifile = false) + { + if (isMultifile) + FileName = new List(fileName.Split(',')); + else + FileName = [fileName]; + } + + public ResourceEntity(string fileName) + { + FileName = [fileName]; + } + + public List FileName { get; private set; } + + [Obsolete("No effect")] public bool IsCritical { get; private set; } // deprecated +} \ No newline at end of file diff --git a/Common/Data/ResourceManager.cs b/Common/Data/ResourceManager.cs new file mode 100644 index 0000000..c2c8f30 --- /dev/null +++ b/Common/Data/ResourceManager.cs @@ -0,0 +1,169 @@ +using MikuSB.Internationalization; +using MikuSB.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Reflection; + +namespace MikuSB.Data; + +public class ResourceManager +{ + public static Logger Logger { get; } = new("ResourceManager"); + public static bool IsLoaded { get; set; } + + public static void LoadGameData() + { + LoadExcel(); + } + + public static void LoadExcel() + { + var classes = Assembly.GetExecutingAssembly().GetTypes(); // Get all classes in the assembly + List resList = []; + + foreach (var cls in classes.Where(x => x.IsSubclassOf(typeof(ExcelResource)))) + { + var res = LoadSingleExcelResource(cls); + if (res != null) resList.AddRange(res); + } + + foreach (var cls in resList) cls.AfterAllDone(); + } + + public static List? LoadSingleExcel(Type cls) where T : ExcelResource, new() + { + return LoadSingleExcelResource(cls) as List; + } + + public static List? LoadSingleExcelResource(Type cls) + { + var attribute = (ResourceEntity?)Attribute.GetCustomAttribute(cls, typeof(ResourceEntity)); + + if (attribute == null) return null; + var resource = (ExcelResource)Activator.CreateInstance(cls)!; + var count = 0; + List resList = []; + foreach (var fileName in attribute.FileName) + try + { + var path = ConfigManager.Config.Path.ResourcePath + "/ExcelOutput/" + fileName; + var file = new FileInfo(path); + if (!file.Exists) + { + Logger.Error(I18NManager.Translate("Server.ServerInfo.FailedToReadItem", fileName, + I18NManager.Translate("Word.NotFound"))); + continue; + } + + var json = file.OpenText().ReadToEnd(); + using (var reader = new JsonTextReader(new StringReader(json))) + { + reader.Read(); + switch (reader.TokenType) + { + case JsonToken.StartArray: + { + // array + var jArray = JArray.Parse(json); + foreach (var item in jArray) + { + var res = JsonConvert.DeserializeObject(item.ToString(), cls); + resList.Add((ExcelResource)res!); + ((ExcelResource?)res)?.Loaded(); + count++; + } + + break; + } + case JsonToken.StartObject: + { + // dictionary + var jObject = JObject.Parse(json); + foreach (var (_, obj) in jObject) + { + var instance = JsonConvert.DeserializeObject(obj!.ToString(), cls); + + if (((ExcelResource?)instance)?.GetId() == 0 || (ExcelResource?)instance == null) + { + // Deserialize as JObject to handle nested dictionaries + var nestedObject = JsonConvert.DeserializeObject(obj.ToString()); + + foreach (var nestedItem in nestedObject ?? []) + { + var nestedInstance = + JsonConvert.DeserializeObject(nestedItem.Value!.ToString(), cls); + resList.Add((ExcelResource)nestedInstance!); + ((ExcelResource?)nestedInstance)?.Loaded(); + count++; + } + } + else + { + resList.Add((ExcelResource)instance); + ((ExcelResource)instance).Loaded(); + } + + count++; + } + + break; + } + } + } + + resource.Finalized(); + } + catch (Exception ex) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToReadItem", fileName, + I18NManager.Translate("Word.Error")), ex); + } + + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItems", count.ToString(), cls.Name)); + + return resList; + } + + public static T? LoadCustomFile(string filetype, string filename) + { + var type = I18NManager.Translate("Word." + filetype); + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", type)); + FileInfo file = new(ConfigManager.Config.Path.DataPath + $"/{filename}.json"); + T? customFile = default; + if (!file.Exists) + { + Logger.Warn(I18NManager.Translate("Server.ServerInfo.ConfigMissing", type, + $"{ConfigManager.Config.Path.DataPath}/{filename}.json", type)); + return customFile; + } + + try + { + using var reader = file.OpenRead(); + using StreamReader reader2 = new(reader); + var text = reader2.ReadToEnd(); + var json = JsonConvert.DeserializeObject(text); + customFile = json; + } + catch (Exception ex) + { + Logger.Error("Error in reading " + file.Name, ex); + } + + switch (customFile) + { + case Dictionary d: + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItems", d.Count.ToString(), type)); + break; + case Dictionary> di: + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItems", di.Count.ToString(), type)); + break; + default: + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItem", filetype)); + break; + } + + return customFile; + } +} \ No newline at end of file diff --git a/Common/Database/Account/AccountData.cs b/Common/Database/Account/AccountData.cs new file mode 100644 index 0000000..48d43e7 --- /dev/null +++ b/Common/Database/Account/AccountData.cs @@ -0,0 +1,181 @@ +using MikuSB.Enums.Player; +using MikuSB.Util; +using MikuSB.Util.Extensions; +using MikuSB.Util.Security; +using SqlSugar; + +namespace MikuSB.Database.Account; + +[SugarTable("Account")] +public class AccountData : BaseDatabaseDataHelper +{ + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public BanTypeEnum BanType { get; set; } + public string Phone { get; set; } = ""; + + [SugarColumn(IsJson = true)] public List Permissions { get; set; } = []; + + [SugarColumn(IsNullable = true)] public string? ComboToken { get; set; } + [SugarColumn(IsNullable = true)] public string? DispatchToken { get; set; } + + #region GetAccount + + public static AccountData? GetAccountByUserName(string username) + { + AccountData? result = null; + DatabaseHelper.GetAllInstance()?.ForEach(account => + { + if (account.Username == username) result = account; + }); + return result; + } + + public static AccountData? GetAccountByUid(int uid, bool force = false) + { + var result = DatabaseHelper.GetInstance(uid, force); + return result; + } + + public static AccountData? GetAccountByDispatchToken(string dispatchToken) + { + AccountData? result = null; + DatabaseHelper.GetAllInstance()?.ForEach(account => + { + if (account.DispatchToken == dispatchToken) result = account; + }); + return result; + } + + public static AccountData? GetAccountByComboToken(string comboToken) + { + AccountData? result = null; + DatabaseHelper.GetAllInstance()?.ForEach(account => + { + if (account.ComboToken == comboToken) result = account; + }); + return result; + } + + #endregion + + #region Account + + public static void CreateAccount(string username, int uid, string password) + { + var newUid = uid; + if (uid == 0) + { + newUid = 1; + while (GetAccountByUid(newUid) != null) newUid++; + } + + var account = new AccountData + { + Uid = newUid, + Username = username, + Password = password, + Phone = "123456", + Permissions = [.. ConfigManager.Config.ServerOption.DefaultPermissions + .Select(perm => Enum.TryParse(perm, out PermEnum result) ? result : (PermEnum?)null) + .Where(result => result.HasValue).Select(result => result!.Value)] + + }; + SetPassword(account, password); + + DatabaseHelper.CreateInstance(account); + } + + public static void DeleteAccount(int uid) + { + if (GetAccountByUid(uid) == null) return; + DatabaseHelper.DeleteAllInstance(uid); + } + + public static void SetPassword(AccountData account, string password) + { + if (password.Length > 0) + account.Password = Extensions.GetSha256Hash(password); + else + account.Password = ""; + } + + public static bool VerifyPassword(AccountData account, string password) + => account.Password == Extensions.GetSha256Hash(password); + + + #endregion + + #region Permission + + public static bool HasPerm(PermEnum[] perms, int uid) + { + if (uid == (int)ServerEnum.Console) return true; + var account = GetAccountByUid(uid); + if (account?.Permissions == null) return false; + if (account.Permissions.Contains(PermEnum.Admin)) return true; + + return perms.Any(account.Permissions.Contains); + } + + public static void AddPerm(PermEnum[] perms, int uid) + { + if (uid == (int)ServerEnum.Console) return; + var account = GetAccountByUid(uid); + if (account == null) return; + + account.Permissions ??= []; + foreach (var perm in perms) + { + if (!account.Permissions.Contains(perm)) + { + account.Permissions = [.. account.Permissions, perm]; + } + } + } + + public static void RemovePerm(PermEnum[] perms, int uid) + { + if (uid == (int)ServerEnum.Console) return; + var account = GetAccountByUid(uid); + if (account == null) return; + if (account.Permissions == null) return; + + foreach (var perm in perms) + { + if (account.Permissions.Contains(perm)) + { + account.Permissions = account.Permissions.Except([perm]).ToList(); + } + } + } + + public static void CleanPerm(int uid) + { + if (uid == (int)ServerEnum.Console) return; + var account = GetAccountByUid(uid); + if (account == null) return; + + account.Permissions = []; + } + + #endregion + + #region Token + + public string GenerateDispatchToken() + { + DispatchToken = Crypto.CreateSessionKey(Uid.ToString()); + DatabaseHelper.UpdateInstance(this); + return DispatchToken; + } + + public string GenerateComboToken() + { + ComboToken = Crypto.CreateSessionKey(Uid.ToString()); + DatabaseHelper.UpdateInstance(this); + return ComboToken; + } + + #endregion +} \ No newline at end of file diff --git a/Common/Database/BaseDatabaseDataHelper.cs b/Common/Database/BaseDatabaseDataHelper.cs new file mode 100644 index 0000000..36d53fa --- /dev/null +++ b/Common/Database/BaseDatabaseDataHelper.cs @@ -0,0 +1,8 @@ +using SqlSugar; + +namespace MikuSB.Database; + +public abstract class BaseDatabaseDataHelper +{ + [SugarColumn(IsPrimaryKey = true)] public int Uid { get; set; } +} \ No newline at end of file diff --git a/Common/Database/CustomSerializeService.cs b/Common/Database/CustomSerializeService.cs new file mode 100644 index 0000000..75d52f2 --- /dev/null +++ b/Common/Database/CustomSerializeService.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using SqlSugar; + +namespace MikuSB.Database; + +public class CustomSerializeService : ISerializeService +{ + private readonly JsonSerializerSettings _jsonSettings; + + public CustomSerializeService() + { + _jsonSettings = new JsonSerializerSettings + { + DefaultValueHandling = DefaultValueHandling.Ignore // ignore default values + }; + } + + public string SerializeObject(object value) + { + return JsonConvert.SerializeObject(value, _jsonSettings); + } + + public T DeserializeObject(string value) + { + return JsonConvert.DeserializeObject(value)!; + } + + public string SugarSerializeObject(object value) + { + return JsonConvert.SerializeObject(value, _jsonSettings); + } +} \ No newline at end of file diff --git a/Common/Database/DatabaseHelper.cs b/Common/Database/DatabaseHelper.cs new file mode 100644 index 0000000..1a9dc5f --- /dev/null +++ b/Common/Database/DatabaseHelper.cs @@ -0,0 +1,305 @@ +using MikuSB.Database.Account; +using MikuSB.Internationalization; +using MikuSB.Util; +using SqlSugar; +using System.Collections.Concurrent; +using System.Globalization; + +namespace MikuSB.Database; + +public class DatabaseHelper +{ + public static Logger logger = new("Database"); + public static SqlSugarScope? sqlSugarScope; + public static readonly ConcurrentDictionary> UidInstanceMap = []; + public static readonly List ToSaveUidList = []; + public static long LastSaveTick = DateTime.UtcNow.Ticks; + public static Thread? SaveThread; + public static bool LoadAccount; + public static bool LoadAllData; + + public void Initialize() + { + logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", I18NManager.Translate("Word.Database"))); + var f = new FileInfo(ConfigManager.Config.Path.DatabasePath + "/" + ConfigManager.Config.GameServer.DatabaseName); + if (!f.Exists && f.Directory != null) f.Directory.Create(); + + sqlSugarScope = new SqlSugarScope(new ConnectionConfig + { + ConnectionString = $"Data Source={f.FullName};", + DbType = DbType.Sqlite, + IsAutoCloseConnection = true, + ConfigureExternalServices = new ConfigureExternalServices + { + SerializeService = new CustomSerializeService() + } + }); + + InitializeSqlite(); + + var baseType = typeof(BaseDatabaseDataHelper); + var assembly = typeof(BaseDatabaseDataHelper).Assembly; + var types = assembly.GetTypes().Where(t => t.IsSubclassOf(baseType)); + + var list = sqlSugarScope.Queryable().ToList(); + foreach (var inst in list) + { + if (!UidInstanceMap.TryGetValue(inst.Uid, out var value)) + { + value = []; + UidInstanceMap[inst.Uid] = value; + } + + value.Add(inst); // add to the map + } + + // start dispatch server + LoadAccount = true; + + var res = Parallel.ForEach(list, account => + { + Parallel.ForEach(types, t => + { + if (t == typeof(AccountData)) return; // skip the account data + + try + { + typeof(DatabaseHelper).GetMethod(nameof(InitializeTable))?.MakeGenericMethod(t) + .Invoke(null, [account.Uid]); + } + catch (Exception e) + { + logger.Error("Database initialization error: ", e); + } + + }); // cache the data + }); + + while (!res.IsCompleted) + { + } + + LastSaveTick = DateTime.UtcNow.Ticks; + + SaveThread = new Thread(() => + { + while (true) CalcSaveDatabase(); + }); + SaveThread.Start(); + + LoadAllData = true; + } + + public static void InitializeTable(int uid) where T : BaseDatabaseDataHelper, new() + { + var list = sqlSugarScope?.Queryable() + .Select(x => x) + .Select() + .Where(x => x.Uid == uid) + .ToList(); + + foreach (var inst in list!.Select(instance => (instance as BaseDatabaseDataHelper)!)) + { + if (!UidInstanceMap.TryGetValue(inst.Uid, out var value)) + { + value = []; + UidInstanceMap[inst.Uid] = value; + } + + value.Add(inst); // add to the map + } + } + + public static void InitializeSqlite() + { + var baseType = typeof(BaseDatabaseDataHelper); + var assembly = typeof(BaseDatabaseDataHelper).Assembly; + var types = assembly.GetTypes().Where(t => t.IsSubclassOf(baseType)); + foreach (var type in types) + typeof(DatabaseHelper).GetMethod("InitializeSqliteTable")?.MakeGenericMethod(type).Invoke(null, null); + } + + // DO NOT DEL ReSharper disable once UnusedMember.Global + public static void InitializeSqliteTable() where T : BaseDatabaseDataHelper, new() + { + try + { + sqlSugarScope?.CodeFirst.InitTables(); + } + catch + { + // ignored + } + } + + public static T? GetInstance(int uid, bool forceReload = false) where T : BaseDatabaseDataHelper, new() + { + try + { + if (!forceReload && UidInstanceMap.TryGetValue(uid, out var value)) + { + var instance = value.OfType().FirstOrDefault(); + if (instance != null) return instance; + } + var t = sqlSugarScope?.Queryable() + .Where(x => x.Uid == uid) + .ToList(); + + if (t is { Count: > 0 }) + { + var instance = t[0]; + + if (!UidInstanceMap.TryGetValue(uid, out var list)) + { + list = new List(); + UidInstanceMap[uid] = list; + } + else + { + list.RemoveAll(i => i is T); + } + + list.Add(instance); + return instance; + } + + return null; + } + catch (Exception e) + { + logger.Error("Unsupported type", e); + return null; + } + } + + public static T GetInstanceOrCreateNew(int uid) where T : BaseDatabaseDataHelper, new() + { + var instance = GetInstance(uid); + if (instance != null) return instance; + + instance = new T + { + Uid = uid + }; + CreateInstance(instance); + + return instance; + } + + public static List? GetAllInstance() where T : BaseDatabaseDataHelper, new() + { + try + { + return sqlSugarScope?.Queryable() + .Select(x => x) + .ToList(); + } + catch (Exception e) + { + logger.Error("Unsupported type", e); + return null; + } + } + + public static void UpdateInstance(T instance) where T : BaseDatabaseDataHelper, new() + { + sqlSugarScope?.Updateable(instance).ExecuteCommand(); + } + + public static void CreateInstance(T instance) where T : BaseDatabaseDataHelper, new() + { + sqlSugarScope?.Insertable(instance).ExecuteCommand(); + if (!UidInstanceMap.TryGetValue(instance.Uid, out var value)) + { + value = []; + UidInstanceMap[instance.Uid] = value; + } + value.Add(instance); + } + + public static void DeleteInstance(int key) where T : BaseDatabaseDataHelper, new() + { + try + { + sqlSugarScope?.Deleteable().Where(x => x.Uid == key).ExecuteCommand(); + } + catch (Exception e) + { + logger.Error("An error occurred while delete the database", e); + } + } + + public static void DeleteAllInstance(int key) + { + + var value = UidInstanceMap[key]; + var baseType = typeof(BaseDatabaseDataHelper); + var assembly = typeof(BaseDatabaseDataHelper).Assembly; + var types = assembly.GetTypes().Where(t => t.IsSubclassOf(baseType)); + foreach (var type in types) + { + var instance = value.Find(x => x.GetType() == type); + if (instance != null) + typeof(DatabaseHelper).GetMethod("DeleteInstance")?.MakeGenericMethod(type) + .Invoke(null, [key]); + } + + if (UidInstanceMap.TryRemove(key, out var instances)) + ToSaveUidList.RemoveAll(x => x == key); + } + + // Auto save per 5 min + public static void CalcSaveDatabase() + { + if (LastSaveTick + TimeSpan.TicksPerMinute * 5 > DateTime.UtcNow.Ticks) return; + SaveDatabase(); + } + + public static void SaveDatabase() + { + try + { + var prev = DateTime.Now; + var list = ToSaveUidList.ToList(); // copy the list to avoid the exception + foreach (var uid in list) + { + var value = UidInstanceMap[uid]; + var baseType = typeof(BaseDatabaseDataHelper); + var assembly = typeof(BaseDatabaseDataHelper).Assembly; + var types = assembly.GetTypes().Where(t => t.IsSubclassOf(baseType)); + foreach (var type in types) + { + var instance = value.Find(x => x.GetType() == type); + if (instance != null) + typeof(DatabaseHelper).GetMethod("SaveDatabaseType")?.MakeGenericMethod(type) + .Invoke(null, [instance]); + } + } + + var t = (DateTime.Now - prev).TotalSeconds; + logger.Info(I18NManager.Translate("Server.ServerInfo.SaveDatabase", + Math.Round(t, 2).ToString(CultureInfo.InvariantCulture))); + + ToSaveUidList.Clear(); + } + catch (Exception e) + { + logger.Error("An error occurred while saving the database", e); + } + + LastSaveTick = DateTime.UtcNow.Ticks; + } + + // DO NOT DEL ReSharper save database from cache + public static void SaveDatabaseType(T instance) where T : BaseDatabaseDataHelper, new() + { + try + { + sqlSugarScope?.Updateable(instance).ExecuteCommand(); + } + catch (Exception e) + { + logger.Error("An error occurred while saving the database", e); + } + } +} \ No newline at end of file diff --git a/Common/Database/Player/PlayerGameData.cs b/Common/Database/Player/PlayerGameData.cs new file mode 100644 index 0000000..c3c39e9 --- /dev/null +++ b/Common/Database/Player/PlayerGameData.cs @@ -0,0 +1,23 @@ +using MikuSB.Common.Util; +using MikuSB.Proto; +using MikuSB.Util.Extensions; +using SqlSugar; + +namespace MikuSB.Database.Player; + +[SugarTable("Player")] +public class PlayerGameData : BaseDatabaseDataHelper +{ + public string? Name { get; set; } = ""; + public string? Signature { get; set; } = "MikuPS"; + public uint Level { get; set; } = 1; + public int Exp { get; set; } = 0; + public long RegisterTime { get; set; } = Extensions.GetUnixSec(); + public long LastActiveTime { get; set; } + + public static PlayerGameData? GetPlayerByUid(long uid) + { + var result = DatabaseHelper.GetInstance((int)uid); + return result; + } +} \ No newline at end of file diff --git a/Common/Enums/Language/ProgramLanguageTypeEnum.cs b/Common/Enums/Language/ProgramLanguageTypeEnum.cs new file mode 100644 index 0000000..623675c --- /dev/null +++ b/Common/Enums/Language/ProgramLanguageTypeEnum.cs @@ -0,0 +1,9 @@ +namespace MikuSB.Enums.Language; + +public enum ProgramLanguageTypeEnum +{ + EN = 0, + CHS = 1, + CHT = 2, + JP = 3 +} \ No newline at end of file diff --git a/Common/Enums/Packet/PacketFraming.cs b/Common/Enums/Packet/PacketFraming.cs new file mode 100644 index 0000000..2215458 --- /dev/null +++ b/Common/Enums/Packet/PacketFraming.cs @@ -0,0 +1,9 @@ +namespace MikuSB.Enums.Packet; + +public enum PacketFraming +{ + FourByteLittleEndianLength, + TwoByteBigEndianLength, + Control, + Unknown +} \ No newline at end of file diff --git a/Common/Enums/Player/BanTypeEnum.cs b/Common/Enums/Player/BanTypeEnum.cs new file mode 100644 index 0000000..e0ddc54 --- /dev/null +++ b/Common/Enums/Player/BanTypeEnum.cs @@ -0,0 +1,13 @@ +namespace MikuSB.Enums.Player; + +public enum BanTypeEnum +{ + None = 0, + UseThirdPartySoftware = 1, + ThirdPartySoftware = 2, + AbnormalLogin = 4, + AbnormalAccount = 5, + ViolationTermsService = 6, + AccountRisk = 7, + Unknown = 8 +} \ No newline at end of file diff --git a/Common/Enums/Player/FriendEnum.cs b/Common/Enums/Player/FriendEnum.cs new file mode 100644 index 0000000..c4e5c79 --- /dev/null +++ b/Common/Enums/Player/FriendEnum.cs @@ -0,0 +1,7 @@ +namespace MikuSB.Enums.Player; + +public enum ServerEnum +{ + Console = 0, + Chat = 1 +} \ No newline at end of file diff --git a/Common/Enums/Player/PermEnum.cs b/Common/Enums/Player/PermEnum.cs new file mode 100644 index 0000000..fe48bbc --- /dev/null +++ b/Common/Enums/Player/PermEnum.cs @@ -0,0 +1,9 @@ +namespace MikuSB.Enums.Player; + +public enum PermEnum +{ + Trial = 0, + Support = 1, + Admin = 2, + Other = 10 +} \ No newline at end of file diff --git a/Common/Internationalization/I18nManager.cs b/Common/Internationalization/I18nManager.cs new file mode 100644 index 0000000..e6ab84c --- /dev/null +++ b/Common/Internationalization/I18nManager.cs @@ -0,0 +1,102 @@ +using MikuSB.Enums.Language; +using MikuSB.Internationalization.Message; +using MikuSB.Util; +using System.Reflection; + +namespace MikuSB.Internationalization; + +public static class I18NManager +{ + public static Logger Logger = new("I18nManager"); + + public static object Language { get; set; } = new LanguageEN(); + public static Dictionary> PluginLanguages { get; } = []; + + public static void LoadLanguage() + { + var languageStr = "MikuSB.Internationalization.Message.Language" + + ConfigManager.Config.ServerOption.Language; + var languageType = Type.GetType(languageStr); + if (languageType == null) + { + Logger.Warn("Language not found, fallback to EN"); + // fallback to English + languageType = Type.GetType("MikuSB.Internationalization.Message.LanguageEN")!; + } + + var language = Activator.CreateInstance(languageType) ?? throw new Exception("Language not found"); + Language = language; + + Logger.Info(Translate("Server.ServerInfo.LoadedItem", Translate("Word.Language"))); + } + + public static void LoadPluginLanguage(Dictionary> pluginAssemblies) + { + foreach (var (pluginName, types) in pluginAssemblies) + { + var languageType = types.FindAll(x => x.GetCustomAttribute() != null); + if (languageType.Count == 0) // no language to use + continue; + + PluginLanguages.Add(pluginName, []); + foreach (var type in languageType) + { + var attr = type.GetCustomAttribute(); + if (attr == null) continue; + + var language = Activator.CreateInstance(type); + if (language == null) continue; + PluginLanguages[pluginName].Add(attr.LanguageType, language); + } + } + } + + public static string Translate(string key, params string[] args) + { + var pluginLangs = PluginLanguages.Values; + var langs = (from pluginLang in pluginLangs + from o in pluginLang + where o.Key == Enum.Parse(ConfigManager.Config.ServerOption.Language) + select o.Value).ToList(); // get all plugin languages + langs.Add(Language); // add server language + + var result = langs.Select(lang => GetNestedPropertyValue(lang, key)).OfType().FirstOrDefault() ?? key; + + var index = 0; + + return args.Aggregate(result, (current, arg) => current.Replace("{" + index++ + "}", arg)); + } + + public static string TranslateAsCertainLang(string langStr, string key, params string[] args) + { + var languageStr = "MikuSB.Internationalization.Message.Language" + + langStr; + var languageType = Type.GetType(languageStr) ?? + Type.GetType("MikuSB.Internationalization.Message.LanguageEN")!; + var language = Activator.CreateInstance(languageType) ?? throw new Exception("Language not found"); + + List langs = [language]; + + var result = langs.Select(lang => GetNestedPropertyValue(lang, key)).OfType().FirstOrDefault() ?? key; + + var index = 0; + + return args.Aggregate(result, (current, arg) => current.Replace("{" + index++ + "}", arg)); + } + + public static string? GetNestedPropertyValue(object? obj, string propertyName) + { + foreach (var part in propertyName.Split('.')) + { + if (obj == null) return null; + + var type = obj.GetType(); + var property = type.GetProperty(part); + if (property == null) return null; + + obj = property.GetValue(obj, null); + } + + return (string?)obj; + } +} \ No newline at end of file diff --git a/Common/Internationalization/Message/LanguageCHS.cs b/Common/Internationalization/Message/LanguageCHS.cs new file mode 100644 index 0000000..10b9c6d --- /dev/null +++ b/Common/Internationalization/Message/LanguageCHS.cs @@ -0,0 +1,527 @@ +namespace MikuSB.Internationalization.Message; + +#region Root + +public class LanguageCHS +{ + public GameTextCHS Game { get; } = new(); + public ServerTextCHS Server { get; } = new(); + public WordTextCHS Word { get; } = new(); // a placeholder for the actual word text +} + +#endregion + +#region Layer 1 + +/// +/// path: Game +/// +public class GameTextCHS +{ + public CommandTextCHS Command { get; } = new(); +} + +/// +/// path: Server +/// +public class ServerTextCHS +{ + public WebTextCHS Web { get; } = new(); + public ServerInfoTextCHS ServerInfo { get; } = new(); +} + +/// +/// path: Word +/// +public class WordTextCHS +{ + public string Rank => "星魂"; + public string Avatar => "角色"; + public string Material => "材料"; + public string Pet => "宠物"; + public string Relic => "遗器"; + public string Equipment => "光锥"; + public string Talent => "行迹"; + public string Banner => "卡池"; + public string Activity => "活动"; + public string CdKey => "兑换码"; + public string VideoKey => "过场动画密钥"; + public string Buff => "祝福"; + public string Miracle => "奇物"; + public string Unlock => "奢侈品"; + public string TrainParty => "派对车厢"; + + // server info + public string Config => "配置文件"; + public string Language => "语言"; + public string Log => "日志"; + public string GameData => "游戏数据"; + public string Cache => "资源缓存"; + public string CustomData => "自定义数据"; + public string Database => "数据库"; + public string Command => "命令"; + public string SSL => "SSL"; + public string Ec2b => "Ec2b"; + public string SdkServer => "Web服务器"; + public string Handler => "包处理器"; + public string Dispatch => "全局分发"; + public string Game => "游戏"; + public string Handbook => "手册"; + public string NotFound => "未找到"; + public string Error => "错误"; + public string FloorInfo => "区域文件"; + public string FloorGroupInfo => "区域组文件"; + public string FloorMissingResult => "传送与世界生成"; + public string FloorGroupMissingResult => "传送、怪物战斗与世界生成"; + public string Mission => "任务"; + public string MissionInfo => "任务文件"; + public string SubMission => "子任务"; + public string SubMissionInfo => "子任务文件"; + public string MazeSkill => "角色秘技"; + public string MazeSkillInfo => "角色秘技文件"; + public string Dialogue => "模拟宇宙事件"; + public string DialogueInfo => "模拟宇宙事件文件"; + public string Performance => "剧情操作"; + public string PerformanceInfo => "剧情操作文件"; + public string RogueChestMap => "模拟宇宙地图"; + public string RogueChestMapInfo => "模拟宇宙地图文件"; + public string ChessRogueRoom => "模拟宇宙DLC"; + public string ChessRogueRoomInfo => "模拟宇宙DLC文件"; + public string SummonUnit => "秘技生成"; + public string SummonUnitInfo => "秘技生成文件"; + public string RogueTournRoom => "差分宇宙"; + public string RogueTournRoomInfo => "差分宇宙房间文件"; + public string TypesOfRogue => "类型的模拟宇宙"; + public string RogueMagicRoom => "不可知域"; + public string RogueMagicRoomInfo => "不可知域房间文件"; + public string RogueDiceSurface => "骰面效果"; + public string RogueDiceSurfaceInfo => "骰面效果文件"; + public string AdventureModifier => "AdventureModifier"; + public string AdventureModifierInfo => "AdventureModifier文件"; + public string RogueMapGen => "RogueMapGen文件"; + public string RogueMiracleGroup => "RogueMiracleGroup文件"; + public string RogueMiracleEffectGen => "RogueMiracleEffectGen文件"; + + public string DatabaseAccount => "数据库账号"; + public string Tutorial => "教程"; +} + +#endregion + +#region Layer 2 + +#region GameText + +/// +/// path: Game.Command +/// +public class CommandTextCHS +{ + public NoticeTextCHS Notice { get; } = new(); + + public GenderTextCHS Gender { get; } = new(); + public AvatarTextCHS Avatar { get; } = new(); + public AnnounceTextCHS Announce { get; } = new(); + public BanTextCHS Ban { get; } = new(); + public GiveTextCHS Give { get; } = new(); + public GiveAllTextCHS GiveAll { get; } = new(); + public LineupTextCHS Lineup { get; } = new(); + public HelpTextCHS Help { get; } = new(); + public KickTextCHS Kick { get; } = new(); + public MissionTextCHS Mission { get; } = new(); + public RelicTextCHS Relic { get; } = new(); + public ReloadTextCHS Reload { get; } = new(); + public RogueTextCHS Rogue { get; } = new(); + public SceneTextCHS Scene { get; } = new(); + public UnlockAllTextCHS UnlockAll { get; } = new(); + public MailTextCHS Mail { get; } = new(); + public RaidTextCHS Raid { get; } = new(); + public AccountTextCHS Account { get; } = new(); + public UnstuckTextCHS Unstuck { get; } = new(); + public SetlevelTextCHS Setlevel { get; } = new(); + public PermissionTextCHS Permission { get; } = new(); +} + +#endregion + +#region ServerText + +/// +/// path: Server.Web +/// +public class WebTextCHS +{ + public string Maintain => "服务器正在维修, 请稍后尝试。"; +} + +/// +/// path: Server.ServerInfo +/// +public class ServerInfoTextCHS +{ + public string Shutdown => "关闭中…"; + public string CancelKeyPressed => "已按下取消键 (Ctrl + C), 服务器即将关闭…"; + public string StartingServer => "正在启动 MikuSB"; + public string CurrentVersion => "当前服务端支持的版本: {0}"; + public string InvalidVersion => "当前为不受支持的游戏版本 {0}\n请更新游戏到 {1}"; + public string LoadingItem => "正在加载 {0}…"; + public string GeneratingItem => "正在生成 {0}…"; + public string WaitingItem => "正在等待进程 {0} 完成…"; + public string RegisterItem => "注册了 {0} 个 {1}。"; + public string FailedToLoadItem => "加载 {0} 失败。"; + public string NewClientSecretKey => "客户端密钥不存在, 正在生成新的客户端密钥。"; + public string FailedToInitializeItem => "初始化 {0} 失败。"; + public string FailedToReadItem => "读取 {0} 失败, 文件{1}"; + public string GeneratedItem => "已生成 {0}。"; + public string LoadedItem => "已加载 {0}。"; + public string LoadedItems => "已加载 {0} 个 {1}。"; + public string ServerRunning => "{0} 服务器正在监听 {1}"; + public string ServerStarted => "启动完成!用时 {0}s, 击败了99%的用户, 输入 ‘help’ 来获取命令帮助"; // 玩梗, 考虑英语版本将其本土化 + public string MissionEnabled => "任务系统已启用, 此功能仍在开发中, 且可能不会按预期工作, 如果遇见任何bug, 请汇报给开发者。"; + public string KeyStoreError => "SSL证书不存在, 已关闭SSL功能。"; + public string CacheLoadSkip => "已跳过缓存加载。"; + + public string ConfigMissing => "{0} 缺失, 请检查你的资源文件夹: {1}, {2} 可能不能使用。"; + public string UnloadedItems => "卸载了所有 {0}。"; + public string SaveDatabase => "已保存数据库, 用时 {0}s"; + public string WaitForAllDone => "现在还不可以进入游戏, 请等待所有项目加载完成后再试"; + + public string UnhandledException => "发生未经处理的异常: {0}"; +} + +#endregion + +#endregion + +#region Layer 3 + +#region CommandText + +/// +/// path: Game.Command.Notice +/// +public class NoticeTextCHS +{ + public string PlayerNotFound => "未找到玩家!"; + public string InvalidArguments => "无效的参数!"; + public string NoPermission => "你没有权限这么做!"; + public string CommandNotFound => "未找到命令! 输入 '/help' 来获取帮助"; + public string TargetOffline => "目标 {0}({1}) 离线了!清除当前目标"; + public string TargetFound => "找到目标 {0}({1}), 下一次命令将默认对其执行"; + public string TargetNotFound => "未找到目标 {0}!"; + public string InternalError => "在处理命令时发生了内部错误: {0}!"; +} + +/// +/// path: Game.Command.Gender +/// +public class GenderTextCHS +{ + public string Desc => "切换主角的性别"; + public string Usage => "用法: /gender [man/woman]"; + public string GenderNotSpecified => "性别不存在!"; + public string GenderChanged => "性别已更改!"; +} + +/// +/// path: Game.Command.UnlockAll +/// +public class UnlockAllTextCHS +{ + public string Desc => + "解锁所有在类别内的对象\n" + + "使用 /unlockall mission 以完成所有任务, 使用后会被踢出, 重新登录后可能会被教程卡住, 请谨慎使用\n" + + "使用 /unlockall tutorial 以解锁所有教程, 使用后会被踢出, 用于部分界面卡住无法行动的情况\n" + + "使用 /unlockall rogue 以解锁所有类型模拟宇宙, 使用后会被踢出, 建议与 /unlockall tutorial 搭配使用以获取更好效果"; + public string Usage => "用法: /unlockall [mission/tutorial/rogue]"; + public string UnlockedAll => "已解锁/完成所有{0}!"; +} + +/// +/// path: Game.Command.Avatar +/// +public class AvatarTextCHS +{ + public string Desc => "设定玩家已有角色的属性, -1为所有已拥有角色"; + public string Usage => + "用法: /avatar talent [角色ID/-1] [行迹等级]\n" + + "用法: /avatar rank [角色ID/-1] [星魂]\n" + + "用法: /avatar level [角色ID/-1] [角色等级]"; + public string InvalidLevel => "{0} 等级无效!"; + public string AllAvatarsLevelSet => "已将全部角色 {0} 等级设置为 {1}."; + public string AvatarLevelSet => "已将 {0} 角色 {1} 等级设置为 {2}."; + public string AvatarNotFound => "角色不存在!"; +} + +/// +/// path: Game.Command.Give +/// +public class GiveTextCHS +{ + public string Desc => "给予玩家物品"; + public string Usage => "用法: /give [物品ID] l[等级] x[数量] r[叠影]"; + public string ItemNotFound => "未找到物品!"; + public string GiveItem => "已给予 {0} {1} 个物品 {2}."; +} + +/// +/// path: Game.Command.GiveAll +/// +public class GiveAllTextCHS +{ + public string Desc => "给予玩家全部指定类型的物品"; + public string Usage => + "用法: /giveall avatar r[星魂] l[等级]\n" + + "用法: /giveall material x[数量]\n" + + "用法: /giveall equipment r[叠影] l[等级] x[数量]\n" + + "用法: /giveall relic x[数量]\n" + + "用法: /giveall unlock\n" + + "用法: /giveall train\n" + + "用法: /giveall path"; + public string GiveAllItems => "已给予所有 {0}, 各 {1} 个."; +} + +/// +/// path: Game.Command.Lineup +/// +public class LineupTextCHS +{ + public string Desc => "管理玩家的队伍信息"; + public string Usage => + "用法: /lineup mp\n" + + "用法: /lineup sp\n" + + "用法: /lineup heal"; + public string GainedMp => "成功恢复秘技点!"; + public string GainedSp => "成功恢复能量!"; + public string HealedAllAvatars => "成功治愈当前队伍中的所有角色!"; +} + +/// +/// path: Game.Command.Help +/// +public class HelpTextCHS +{ + public string Desc => "显示帮助信息"; + public string Usage => + "用法: /help\n" + + "用法: /help [命令]"; + public string Commands => "命令: "; + public string CommandPermission => "所需权限: "; + public string CommandAlias => "命令别名: "; +} + +/// +/// path: Game.Command.Kick +/// +public class KickTextCHS +{ + public string Desc => "踢出玩家"; + public string Usage => "用法: /kick"; + public string PlayerKicked => "玩家 {0} 已被踢出!"; +} + +/// +/// path: Game.Command.Mission +/// +public class MissionTextCHS +{ + public string Desc => + "管理玩家的任务\n" + + "使用 pass 完成当前正在进行的所有任务, 此命令易造成严重卡顿, 请尽量使用 /mission finish 替代\n" + + "使用 finish [子任务ID] 完成指定子任务, 请浏览 handbook 来获取子任务ID\n" + + "使用 finishmain [主任务ID] 完成指定主任务, 请浏览 handbook 来获取主任务ID\n" + + "使用 running [-all] 获取正在追踪的任务, 增加'-all'则显示所有正在进行的任务以及可能卡住的任务, 使用后可能会出现较长任务列表, 请注意甄别\n" + + "使用 reaccept [主任务ID] 可重新进行指定主任务, 请浏览 handbook 来获取主任务ID"; + public string Usage => + "用法: /mission pass\n" + + "用法: /mission finish [子任务ID]\n" + + "用法: /mission running [-all]\n" + + "用法: /mission reaccept [主任务ID]\n" + + "用法: /mission finishmain [主任务ID]"; + public string AllMissionsFinished => "所有任务已完成!"; + public string AllRunningMissionsFinished => "共 {0} 个进行中的任务已完成!"; + public string MissionFinished => "任务 {0} 已完成!"; + public string InvalidMissionId => "无效的任务ID!"; + public string NoRunningMissions => "没有正在进行的任务!"; + public string RunningMissions => "正在进行的任务: "; + public string PossibleStuckMissions => "可能卡住的任务: "; + public string MainMission => "主任务"; + public string MissionReAccepted => "重新接受任务 {0}."; +} + +/// +/// path: Game.Command.Relic +/// +public class RelicTextCHS +{ + public string Desc => "管理玩家的遗器, 等级限制: 1 ≤ 等级 ≤ 9999"; + public string Usage => "用法: /relic [遗器ID] [主词条ID] [ID1:等级] [ID2:等级] l[等级] x[数量]"; + public string RelicNotFound => "遗器不存在!"; + public string InvalidMainAffixId => "主词条ID无效!"; + public string InvalidSubAffixId => "副词条ID无效!"; + public string RelicGiven => "给予玩家 {0} {1} 个遗器 {2}."; +} + +/// +/// path: Game.Command.Reload +/// +public class ReloadTextCHS +{ + public string Desc => "重新加载指定的配置"; + public string Usage => "用法: /reload [banner/activity]"; + public string ConfigReloaded => "配置 {0} 已重新加载!"; +} + +/// +/// path: Game.Command.Rogue +/// +public class RogueTextCHS +{ + public string Desc => "管理模拟宇宙数据, -1意为所有已拥有祝福, buff获取祝福, enhance强化祝福"; + public string Usage => + "用法: /rogue money [宇宙碎片数量]\n" + + "用法: /rogue buff [祝福ID/-1]\n" + + "用法: /rogue miracle [奇物ID]\n" + + "用法: /rogue enhance [祝福ID/-1]\n" + + "用法: /rogue unstuck - 脱离事件"; + public string PlayerGainedMoney => "已获得 {0} 宇宙碎片."; + public string PlayerGainedAllItems => "已获得所有{0}."; + public string PlayerGainedItem => "已获得{0} {1}."; + public string PlayerEnhancedBuff => "已强化祝福 {0}."; + public string PlayerEnhancedAllBuffs => "已强化所有祝福."; + public string PlayerUnstuck => "已脱离事件."; + public string NotFoundItem => "未找到 {0}!"; + public string PlayerNotInRogue => "玩家不在模拟宇宙中!"; +} + +/// +/// path: Game.Command.Scene +/// +public class SceneTextCHS +{ + public string Desc => + "管理玩家场景\n" + + "使用 PlaneId 默认进入指定场景\n" + + "使用 group 来获取组, 使用 prop 来设置道具状态, 在 PropStateEnum 获取状态列表\n" + + "使用 unlockall 来解锁场景内所有道具(open状态), 可能导致游戏加载卡条, 使用 /scene reset 解决\n" + + "使用 reload 来重新加载当前场景, 并回到初始位置\n" + + "使用 reset 来重置指定场景所有道具状态"; + public string Usage => + "用法: /scene [PlaneId]\n" + + "用法: /scene cur\n" + + "用法: /scene reload\n" + + "用法: /scene group\n" + + "用法: /scene unlockall\n" + + "用法: /scene reset [PlaneId]" + + "用法: /scene prop [组ID] [道具ID] [状态]\n" + + "用法: /scene remove [实体ID]\n"; + + public string LoadedGroups => "已加载组: {0}."; + public string PropStateChanged => "道具: {0} 的状态已设置为 {1}."; + public string PropNotFound => "未找到道具!"; + public string EntityRemoved => "实体 {0} 已被移除."; + public string EntityNotFound => "未找到实体!"; + public string AllPropsUnlocked => "所有道具已解锁!"; + public string SceneChanged => "已进入场景 {0}."; + public string SceneReloaded => "场景已重新加载!"; + public string SceneReset => "已重置场景 {0} 中所有道具状态!"; + public string CurrentScene => "当前场景 EntryId: {0}, PlaneId: {1}, FloorId: {2}."; +} + +/// +/// path: Game.Command.Mail +/// +public class MailTextCHS +{ + public string Desc => "发送邮件"; + public string Usage => "用法: /mail [发送名称] [标题] [内容] [ID1:数量,ID2:数量]"; + public string MailSent => "邮件已发送!"; +} + +/// +/// path: Game.Command.Raid +/// +public class RaidTextCHS +{ + public string Desc => "管理玩家的任务临时场景"; + public string Usage => "用法: /raid leave"; + public string Leaved => "已离开临时场景!"; +} + +/// +/// path: Game.Command.Account +/// +public class AccountTextCHS +{ + public string Desc => "管理数据库账号"; + public string Usage => + "用法: /account create [用户名] [UID] [密码]\n" + + "用法: /account delete [UID]"; + public string InvalidUid => "UID无效!"; + public string InvalidAccount => "账号 {0} 无效!"; + public string CreateSuccess => "账号 {0} 创建成功!"; + public string DeleteSuccess => "账号 {0} 删除成功!"; +} + +/// +/// path: Game.Command.Announce +/// +public class AnnounceTextCHS +{ + public string Desc => "发送弹窗公告"; + public string Usage => "用法: /announce [Text] [Color]"; + public string SendSuccess => "发送成功!"; +} + +/// +/// path: Game.Command.Ban +/// +public class BanTextCHS +{ + public string Desc => "封禁或解封用户"; + public string Usage => "用法: /ban [add/delete]"; + public string BanSuccess => "账号已封禁!"; + public string UnBanSuccess => "账号已解封!"; +} + +/// +/// path: Game.Command.Unstuck +/// +public class UnstuckTextCHS +{ + public string Desc => "将玩家传送回默认场景"; + public string Usage => "用法: /unstuck [UID]"; + public string UnstuckSuccess => "已成功将该玩家传送回默认场景."; + public string UidNotExist => "该UID不存在!"; + public string PlayerIsOnline => "该玩家目前在线上!"; +} + +/// +/// path: Game.Command.Setlevel +/// +public class SetlevelTextCHS +{ + public string Desc => "设定玩家等级"; + public string Usage => "用法: /setlevel [等级]"; + public string SetlevelSuccess => "等级设定成功!"; +} + +/// +/// path: Game.Command.Permission +/// +public class PermissionTextCHS +{ + public string Desc => "管理玩家权限"; + public string Usage => + "用法: /permission add [权限]\n" + + "用法: /permission remove [权限]\n" + + "用法: /permission clean [权限]"; + public string InvalidPerm => "权限 {0} 不存在!"; + public string Added => "已添加权限 {0} 到玩家 {1}!"; + public string Removed => "已移除玩家 {0} 的权限 {1}!"; + public string Cleaned => "已清除玩家 {0} 的所有权限!"; +} + +#endregion + +#endregion \ No newline at end of file diff --git a/Common/Internationalization/Message/LanguageCHT.cs b/Common/Internationalization/Message/LanguageCHT.cs new file mode 100644 index 0000000..18617f9 --- /dev/null +++ b/Common/Internationalization/Message/LanguageCHT.cs @@ -0,0 +1,530 @@ +namespace MikuSB.Internationalization.Message; + +#region Root + +public class LanguageCHT +{ + public GameTextCHT Game { get; } = new(); + public ServerTextCHT Server { get; } = new(); + public WordTextCHT Word { get; } = new(); // a placeholder for the actual word text +} + +#endregion + +#region Layer 1 + +/// +/// path: Game +/// +public class GameTextCHT +{ + public CommandTextCHT Command { get; } = new(); +} + +/// +/// path: Server +/// +public class ServerTextCHT +{ + public WebTextCHT Web { get; } = new(); + public ServerInfoTextCHT ServerInfo { get; } = new(); +} + +/// +/// path: Word +/// +public class WordTextCHT +{ + public string Rank => "星魂"; + public string Avatar => "角色"; + public string Material => "材料"; + public string Pet => "寵物"; + public string Relic => "遺器"; + public string Equipment => "光錐"; + public string Talent => "行跡"; + public string Banner => "卡池"; + public string Activity => "活動"; + public string CdKey => "兌換碼"; + public string VideoKey => "過場動畫金鑰"; + public string Buff => "祝福"; + public string Miracle => "奇物"; + public string Unlock => "奢侈品"; + public string TrainParty => "派對車廂"; + + // server info + public string Config => "配置文件"; + public string Language => "語言"; + public string Log => "日誌"; + public string GameData => "遊戲數據"; + public string Cache => "資源緩存"; + public string CustomData => "自定義數據"; + public string Database => "數據庫"; + public string Command => "命令"; + public string SSL => "SSL"; + public string Ec2b => "Ec2b"; + public string SdkServer => "Web服務器"; + public string Handler => "包處理器"; + public string Dispatch => "全局分發"; + public string Game => "遊戲"; + public string Handbook => "手冊"; + public string NotFound => "未找到"; + public string Error => "錯誤"; + public string FloorInfo => "區域文件"; + public string FloorGroupInfo => "區域組文件"; + public string FloorMissingResult => "傳送與世界生成"; + public string FloorGroupMissingResult => "傳送、怪物戰鬥與世界生成"; + public string Mission => "任務"; + public string MissionInfo => "任務文件"; + public string SubMission => "子任務"; + public string SubMissionInfo => "子任務文件"; + public string MazeSkill => "角色秘技"; + public string MazeSkillInfo => "角色秘技文件"; + public string Dialogue => "模擬宇宙事件"; + public string DialogueInfo => "模擬宇宙事件文件"; + public string Performance => "劇情操作"; + public string PerformanceInfo => "劇情操作文件"; + public string RogueChestMap => "模擬宇宙地圖"; + public string RogueChestMapInfo => "模擬宇宙地圖文件"; + public string ChessRogueRoom => "模擬宇宙DLC"; + public string ChessRogueRoomInfo => "模擬宇宙DLC文件"; + public string SummonUnit => "秘技生成"; + public string SummonUnitInfo => "秘技生成文件"; + public string RogueTournRoom => "差分宇宙"; + public string RogueTournRoomInfo => "差分宇宙房間文件"; + public string TypesOfRogue => "類型的模擬宇宙"; + public string RogueMagicRoom => "不可知域"; + public string RogueMagicRoomInfo => "不可知域房間文件"; + public string RogueDiceSurface => "骰面效果"; + public string RogueDiceSurfaceInfo => "骰面效果文件"; + public string AdventureModifier => "AdventureModifier"; + public string AdventureModifierInfo => "AdventureModifier文件"; + public string RogueMapGen => "RogueMapGen文件"; + public string RogueMiracleGroup => "RogueMiracleGroup文件"; + public string RogueMiracleEffectGen => "RogueMiracleEffectGen文件"; + + public string DatabaseAccount => "數據庫賬號"; + public string Tutorial => "教程"; +} + +#endregion + +#region Layer 2 + +#region GameText + +/// +/// path: Game.Command +/// +public class CommandTextCHT +{ + public NoticeTextCHT Notice { get; } = new(); + + public GenderTextCHT Gender { get; } = new(); + public AvatarTextCHT Avatar { get; } = new(); + public AnnounceTextCHT Announce { get; } = new(); + public BanTextCHT Ban { get; } = new(); + public GiveTextCHT Give { get; } = new(); + public GiveAllTextCHT GiveAll { get; } = new(); + public LineupTextCHT Lineup { get; } = new(); + public HelpTextCHT Help { get; } = new(); + public KickTextCHT Kick { get; } = new(); + public MissionTextCHT Mission { get; } = new(); + public RelicTextCHT Relic { get; } = new(); + public ReloadTextCHT Reload { get; } = new(); + public RogueTextCHT Rogue { get; } = new(); + public SceneTextCHT Scene { get; } = new(); + public UnlockAllTextCHT UnlockAll { get; } = new(); + public MailTextCHT Mail { get; } = new(); + public RaidTextCHT Raid { get; } = new(); + public AccountTextCHT Account { get; } = new(); + public UnstuckTextCHT Unstuck { get; } = new(); + public SetlevelTextCHT Setlevel { get; } = new(); + public PermissionTextCHT Permission { get; } = new(); +} + +#endregion + +#region ServerText + +/// +/// path: Server.Web +/// +public class WebTextCHT +{ + public string Maintain => "服務器正在維修, 請稍後嘗試。"; +} + +/// +/// path: Server.ServerInfo +/// +public class ServerInfoTextCHT +{ + public string Shutdown => "關閉中…"; + public string CancelKeyPressed => "已按下取消鍵 (Ctrl + C), 服務器即將關閉…"; + public string StartingServer => "正在啟動 MikuSB"; + public string CurrentVersion => "當前服務端支援的版本: {0}"; + public string InvalidVersion => "目前為不受支援的遊戲版本 {0}\n請更新遊戲到 {1}"; + public string LoadingItem => "正在加載 {0}…"; + public string GeneratingItem => "正在生成 {0}…"; + public string WaitingItem => "正在等待進程 {0} 完成…"; + public string RegisterItem => "註冊了 {0} 個 {1}。"; + public string FailedToLoadItem => "加載 {0} 失敗。"; + public string NewClientSecretKey => "客戶端密鑰不存在, 正在生成新的客戶端密鑰。"; + public string FailedToInitializeItem => "初始化 {0} 失敗。"; + public string FailedToReadItem => "讀取 {0} 失敗, 文件{1}"; + public string GeneratedItem => "已生成 {0}。"; + public string LoadedItem => "已加載 {0}。"; + public string LoadedItems => "已加載 {0} 個 {1}。"; + public string ServerRunning => "{0} 服務器正在監聽 {1}"; + public string ServerStarted => "啟動完成!用時 {0}s, 擊敗了99%的用戶, 輸入 『help』 來獲取命令幫助"; // 玩梗, 考慮英語版本將其本土化 + public string MissionEnabled => "任務系統已啟用, 此功能仍在開發中, 且可能不會按預期工作, 如果遇見任何bug, 請匯報給開發者。"; + public string KeyStoreError => "SSL證書不存在, 已關閉SSL功能。"; + public string CacheLoadSkip => "已跳過緩存加載。"; + + public string ConfigMissing => "{0} 缺失, 請檢查你的資源文件夾: {1}, {2} 可能不能使用。"; + public string UnloadedItems => "卸載了所有 {0}。"; + public string SaveDatabase => "已保存數據庫, 用時 {0}s"; + public string WaitForAllDone => "現在還不可以進入遊戲, 請等待所有項目加載完成後再試"; + + public string UnhandledException => "發生未經處理的異常: {0}"; +} + +#endregion + +#endregion + +#region Layer 3 + +#region CommandText + +/// +/// path: Game.Command.Notice +/// +public class NoticeTextCHT +{ + public string PlayerNotFound => "未找到玩家!"; + public string InvalidArguments => "無效的參數!"; + public string NoPermission => "你沒有權限這麽做!"; + public string CommandNotFound => "未找到命令! 輸入 '/help' 來獲取幫助"; + public string TargetOffline => "目標 {0}({1}) 離線了!清除當前目標"; + public string TargetFound => "找到目標 {0}({1}), 下一次命令將默認對其執行"; + public string TargetNotFound => "未找到目標 {0}!"; + public string InternalError => "在處理命令時發生了內部錯誤: {0}!"; +} + +/// +/// path: Game.Command.Gender +/// +public class GenderTextCHT +{ + public string Desc => "切換主角的性別"; + public string Usage => "用法: /gender [man/woman]"; + public string GenderNotSpecified => "性別不存在!"; + public string GenderChanged => "性別已更改!"; +} + +/// +/// path: Game.Command.UnlockAll +/// +public class UnlockAllTextCHT +{ + public string Desc => + "解鎖所有在類別內的對象\n" + + "使用 /unlockall mission 以完成所有任務, 使用後會被踢出, 重新登錄後可能會被教程卡住, 請謹慎使用\n" + + "使用 /unlockall tutorial 以解鎖所有教程, 使用後會被踢出, 用於部分界面卡住無法行動的情況\n" + + "使用 /unlockall rogue 以解鎖所有類型模擬宇宙, 使用後會被踢出, 建議與 /unlockall tutorial 搭配使用以獲取更好效果"; + + public string Usage => "用法: /unlockall [mission/tutorial/rogue]"; + public string UnlockedAll => "已解鎖/完成所有{0}!"; +} + +/// +/// path: Game.Command.Avatar +/// +public class AvatarTextCHT +{ + public string Desc => "設定玩家已有角色的屬性, -1意為所有已擁有角色"; + + public string Usage => + "用法: /avatar talent [角色ID/-1] [行跡等級]\n" + + "用法: /avatar rank [角色ID/-1] [星魂]\n" + + "用法: /avatar level [角色ID/-1] [角色等級]"; + public string InvalidLevel => "{0}等級無效!"; + public string AllAvatarsLevelSet => "已將全部角色 {0}等級設置為 {1}."; + public string AvatarLevelSet => "已將 {0} 角色 {1}等級設置為 {2}."; + public string AvatarNotFound => "角色不存在!"; +} + +/// +/// path: Game.Command.Give +/// +public class GiveTextCHT +{ + public string Desc => "給予玩家物品"; + public string Usage => "用法: /give [物品ID] l[等級] x[數量] r[疊影]"; + public string ItemNotFound => "未找到物品!"; + public string GiveItem => "給予 @{0} {1} 個物品 {2}."; +} + +/// +/// path: Game.Command.GiveAll +/// +public class GiveAllTextCHT +{ + public string Desc => "給予玩家全部指定類型的物品"; + public string Usage => + "用法: /giveall avatar r[星魂] l[等級]\n" + + "用法: /giveall material x[數量]\n" + + "用法: /giveall equipment r[叠影] l[等級] x[數量]\n" + + "用法: /giveall relic l<等級> x<數量>\n" + + "用法: /giveall unlock\n" + + "用法: /giveall train\n" + + "用法: /giveall path"; + public string GiveAllItems => "已給予所有 {0}, 各 {1} 個."; +} + +/// +/// path: Game.Command.Lineup +/// +public class LineupTextCHT +{ + public string Desc => "管理玩家的隊伍資訊"; + public string Usage => + "用法: /lineup mp\n" + + "用法: /lineup sp\n" + + "用法: /lineup heal"; + public string GainedMp => "成功恢復秘技點!"; + public string GainedSp => "成功恢復能量!"; + public string HealedAllAvatars => "成功治愈當前隊伍中的所有角色!"; +} + +/// +/// path: Game.Command.Help +/// +public class HelpTextCHT +{ + public string Desc => "顯示幫助信息"; + public string Usage => + "用法: /help\n" + + "用法: /help [命令]"; + public string Commands => "命令: "; + public string CommandPermission => "所需權限: "; + public string CommandAlias => "命令別名: "; +} + +/// +/// path: Game.Command.Kick +/// +public class KickTextCHT +{ + public string Desc => "踢出玩家"; + public string Usage => "用法: /kick"; + public string PlayerKicked => "玩家 {0} 已被踢出!"; +} + +/// +/// path: Game.Command.Mission +/// +public class MissionTextCHT +{ + public string Desc => + "管理玩家的任務\n" + + "使用 pass 完成當前正在進行的所有任務, 此命令易造成嚴重卡頓, 請盡量使用 /mission finish 替代\n" + + "使用 finish [子任務ID] 完成指定子任務, 請流覽 handbook 來獲取子任務ID\n" + + "使用 finishmain [主任務ID] 完成指定主任務, 請流覽 handbook 來獲取主任務ID\n" + + "使用 running [-all] 獲取正在追蹤的任務, 增加'-all'則顯示所有正在進行的任務以及可能卡住的任務, 使用後可能會出現較長任務清單, 請注意甄別\n" + + "使用 reaccept [主任務ID] 可重新進行指定主任務, 請流覽 handbook 來獲取主任務ID"; + + public string Usage => + "用法: /mission pass\n" + + "用法: /mission finish [子任務]\n" + + "用法: /mission running [-all]\n" + + "用法: /mission reaccept [主任務ID]\n" + + "用法: /mission finishmain [主任務ID]"; + public string AllMissionsFinished => "所有任務已完成!"; + public string AllRunningMissionsFinished => "共 {0} 個進行中的任務已完成!"; + public string MissionFinished => "任務 {0} 已完成!"; + public string InvalidMissionId => "無效的任務ID!"; + public string NoRunningMissions => "沒有正在進行的任務!"; + public string RunningMissions => "正在進行的任務: "; + public string PossibleStuckMissions => "可能卡住的任務: "; + public string MainMission => "主任務"; + public string MissionReAccepted => "重新接受任務 {0}."; +} + +/// +/// path: Game.Command.Relic +/// +public class RelicTextCHT +{ + public string Desc => "管理玩家的遺器, 等級限製: 1 ≤ 等級 ≤ 9999"; + public string Usage => "用法: /relic [遺器ID] [主詞條ID] [ID1:等級] [ID2:等級] l[等級] x[數量]"; + public string RelicNotFound => "遺器不存在!"; + public string InvalidMainAffixId => "主詞條ID無效!"; + public string InvalidSubAffixId => "副詞條ID無效!"; + public string RelicGiven => "給予玩家 @{0} {1} 個遺器 {2}, 主詞條 {3}."; +} + +/// +/// path: Game.Command.Reload +/// +public class ReloadTextCHT +{ + public string Desc => "重新加載指定的配置"; + public string Usage => "用法: /reload [banner/activity]"; + public string ConfigReloaded => "配置 {0} 已重新加載!"; +} + +/// +/// path: Game.Command.Rogue +/// +public class RogueTextCHT +{ + public string Desc => "管理模擬宇宙數據, -1意為所有已擁有祝福, buff來獲取祝福, enhance強化祝福"; + + public string Usage => + "用法: /rogue money [宇宙碎片數量]\n" + + "用法: /rogue buff [祝福ID/-1]\n" + + "用法: /rogue miracle [奇物ID]\n" + + "用法: /rogue enhance [祝福ID/-1]\n" + + "用法: /rogue unstuck - 脫離事件"; + public string PlayerGainedMoney => "已獲得 {0} 宇宙碎片."; + public string PlayerGainedAllItems => "已獲得所有{0}."; + public string PlayerGainedItem => "已獲得{0} {1}."; + public string PlayerEnhancedBuff => "已強化祝福 {0}."; + public string PlayerEnhancedAllBuffs => "已強化所有祝福."; + public string PlayerUnstuck => "已脫離事件."; + public string NotFoundItem => "未找到 {0}!"; + public string PlayerNotInRogue => "玩家不在模擬宇宙中!"; +} + +/// +/// path: Game.Command.Scene +/// +public class SceneTextCHT +{ + public string Desc => + "管理玩家場景\n" + + "使用 PlaneId 預設進入指定場景\n" + + "使用 group 來獲取組, 使用 prop 來設置道具狀態, 在 PropStateEnum 獲取狀態列表\n" + + "使用 unlockall 來解鎖場景內所有道具(open狀態), 可能導致遊戲加載卡條, 使用 /scene reset 解決\n" + + "使用 reload 來重新加載當前場景, 並回到初始位置\n" + + "使用 reset 來重置指定場景所有道具狀態"; + public string Usage => + "用法: /scene [entryId]\n" + + "用法: /scene cur\n" + + "用法: /scene reload\n" + + "用法: /scene group\n" + + "用法: /scene unlockall\n" + + "用法: /scene reset [floorId]" + + "用法: /scene prop [組ID] [道具ID] [狀態]\n" + + "用法: /scene remove [實體ID]\n"; + public string LoadedGroups => "已加載組: {0}."; + public string PropStateChanged => "道具: {0} 的狀態已設置為 {1}."; + public string PropNotFound => "未找到道具!"; + public string EntityRemoved => "實體 {0} 已被移除."; + public string EntityNotFound => "未找到實體!"; + public string AllPropsUnlocked => "所有道具已解鎖!"; + public string SceneChanged => "已進入場景 {0}."; + public string SceneReloaded => "場景已重新加載!"; + public string SceneReset => "已重置場景 {0} 中所有道具狀態!"; + public string CurrentScene => "當前場景Entry Id: {0}, Plane Id: {1}, Floor Id: {2}."; +} + +/// +/// path: Game.Command.Mail +/// +public class MailTextCHT +{ + public string Desc => "發送郵件"; + public string Usage => "用法: /mail [發送名稱] [標題] [內容] [ID1:數量,ID2:數量]"; + public string MailSent => "郵件已發送!"; +} + +/// +/// path: Game.Command.Raid +/// +public class RaidTextCHT +{ + public string Desc => "管理玩家的任務臨時場景"; + public string Usage => "用法: /raid leave"; + public string Leaved => "已離開臨時場景!"; +} + +/// +/// path: Game.Command.Account +/// +public class AccountTextCHT +{ + public string Desc => "管理資料庫帳號"; + public string Usage => + "用法: /account create [用户名] [UID] [密碼]\n" + + "用法: /account delete [UID]"; + public string InvalidUid => "UID無效!"; + public string InvalidAccount => "帳號 {0} 無效!"; + public string CreateSuccess => "賬號 {0} 創建成功!"; + public string DeleteSuccess => "賬號 {0} 刪除成功!"; +} + +/// +/// path: Game.Command.Announce +/// +public class AnnounceTextCHT +{ + public string Desc => "發送彈窗公告"; + public string Usage => "用法: /announce [Text] [Color]"; + public string SendSuccess => "發送成功!"; +} + +/// +/// path: Game.Command.Ban +/// +public class BanTextCHT +{ + public string Desc => "封禁或解封用户"; + public string Usage => "用法: /ban [add/delete]"; + public string BanSuccess => "帳號已封禁!"; + public string UnBanSuccess => "帳號已解封!"; +} + +/// +/// path: Game.Command.Unstuck +/// +public class UnstuckTextCHT +{ + public string Desc => "將玩家傳送回默認場景"; + public string Usage => "用法: /unstuck [UID]"; + public string UnstuckSuccess => "已成功將該玩家傳送回默認場景"; + public string UidNotExist => "該UID不存在!"; + public string PlayerIsOnline => "該玩家目前在線上!"; +} + +/// +/// path: Game.Command.Setlevel +/// +public class SetlevelTextCHT +{ + public string Desc => "設定玩家等級"; + public string Usage => "用法: /setlevel [等級]"; + public string SetlevelSuccess => "等級設定成功!"; +} + +/// +/// path: Game.Command.Permission +/// +public class PermissionTextCHT +{ + public string Desc => "管理玩家權限"; + public string Usage => + "用法: /permission add [權限]\n" + + "用法: /permission remove [權限]\n" + + "用法: /permission clean [權限]"; + public string InvalidPerm => "權限 {0} 不存在!"; + public string Added => "已添加權限 {0} 到玩家 {1}!"; + public string Removed => "已移除玩家 {0} 的權限 {1}!"; + public string Cleaned => "已清除玩家 {0} 的所有權限!"; +} + +#endregion + +#endregion \ No newline at end of file diff --git a/Common/Internationalization/Message/LanguageEN.cs b/Common/Internationalization/Message/LanguageEN.cs new file mode 100644 index 0000000..38087f6 --- /dev/null +++ b/Common/Internationalization/Message/LanguageEN.cs @@ -0,0 +1,277 @@ +namespace MikuSB.Internationalization.Message; + +#region Root + +public class LanguageEN +{ + public GameTextEN Game { get; } = new(); + public ServerTextEN Server { get; } = new(); + public WordTextEN Word { get; } = new(); // a placeholder for the actual word text +} + +#endregion + +#region Layer 1 + +/// +/// path: Game +/// +public class GameTextEN +{ + public CommandTextEN Command { get; } = new(); +} + +/// +/// path: Server +/// +public class ServerTextEN +{ + public WebTextEN Web { get; } = new(); + public ServerInfoTextEN ServerInfo { get; } = new(); +} + +/// +/// path: Word +/// +public class WordTextEN +{ + public string Star => "Star"; + public string Valk => "Valkyrie"; + public string Material => "Material"; + public string Stigmata => "Stigmata"; + public string Weapon => "Weapon"; + public string Banner => "Gacha"; + public string Activity => "Activity"; + public string Elf => "Elf"; + public string Dress => "Outfit"; + public string Bracket => "Bracket"; + public string Disturbance => "Disturbance"; + public string Site => "Site"; + + // server info + public string Config => "Config File"; + public string Language => "Language"; + public string Log => "Log"; + public string GameData => "Game Data"; + public string Cache => "Resource Cache"; + public string CustomData => "Custom Data"; + public string Database => "Database"; + public string Command => "Command"; + public string SdkServer => "Web Server"; + public string Handler => "Packet Handler"; + public string Dispatch => "Global Dispatch"; + public string Game => "Game"; + public string Handbook => "Handbook"; + public string NotFound => "Not Found"; + public string Error => "Error"; + public string DatabaseAccount => "Database Account"; + public string Tutorial => "Tutorial"; +} + +#endregion + +#region Layer 2 + +#region GameText + +/// +/// path: Game.Command +/// +public class CommandTextEN +{ + public NoticeTextEN Notice { get; } = new(); + public HelpTextEN Help { get; } = new(); + public ValkTextEN Valk { get; } = new(); + public GiveAllTextEN GiveAll { get; } = new(); + public ElfTextEN Elf { get; } = new(); + public AbyssTextEN Abyss { get; } = new(); + public EndlessTextEN Endless { get; } = new(); +} + +#endregion + +#region ServerTextEN + +/// +/// path: Server.Web +/// +public class WebTextEN +{ + public string Maintain => "The server is undergoing maintenance, please try again later."; +} + +/// +/// path: Server.ServerInfo +/// +public class ServerInfoTextEN +{ + public string Shutdown => "Shutting down..."; + public string CancelKeyPressed => "Cancel key pressed (Ctrl + C), server shutting down..."; + public string StartingServer => "Starting MikuSB"; + public string CurrentVersion => "Server supported versions: {0}"; + public string InvalidVersion => "Unsupported game version {0}\nPlease update game to {1}"; + public string LoadingItem => "Loading {0}..."; + public string GeneratingItem => "Building {0}..."; + public string WaitingItem => "Waiting for process {0} to complete..."; + public string RegisterItem => "Registered {0} {1}(s)."; + public string FailedToLoadItem => "Failed to load {0}."; + public string NewClientSecretKey => "Client Secret Key does not exist and a new Client Secret Key is being generated."; + public string FailedToInitializeItem => "Failed to initialize {0}."; + public string FailedToReadItem => "Failed to read {0}, file {1}"; + public string GeneratedItem => "Generated {0}."; + public string LoadedItem => "Loaded {0}."; + public string LoadedItems => "Loaded {0} {1}(s)."; + public string ServerRunning => "{0} server listening on {1}"; + + public string ServerStarted => + "Startup complete! Took {0}s, better than 99% of users. Type 'help' for command help"; // This is a meme, consider localpermissiong in English + + public string MissionEnabled => + "Mission system enabled. This feature is still in development and may not work as expected. Please report any bugs to the developers."; + public string KeyStoreError => "The SSL certificate does not exist, SSL functionality has been disabled."; + public string CacheLoadSkip => "Skipped cache loading."; + + public string ConfigMissing => "{0} is missing. Please check your resource folder: {1}, {2} may not be available."; + public string UnloadedItems => "Unloaded all {0}."; + public string SaveDatabase => "Database saved in {0}s"; + + public string WaitForAllDone => + "You cannot enter the game yet. Please wait for all items to load before trying again"; + + public string UnhandledException => "An unhandled exception occurred: {0}"; +} + +#endregion + +#endregion + +#region Layer 3 + +#region CommandText + +/// +/// path: Game.Command.Notice +/// +public class NoticeTextEN +{ + public string PlayerNotFound => "Player not found!"; + public string InvalidArguments => "Invalid arguments!"; + public string NoPermission => "You don't have permission!"; + public string CommandNotFound => "Command not found! Type '/help' for assistance"; + public string TargetOffline => "Target {0}({1}) is offline! Clearing current target"; + public string TargetFound => "Target {0}({1}) found. Next command will default to this target"; + public string TargetNotFound => "Target {0} not found!"; + public string InternalError => "Internal error occurred while processing command!"; +} + +/// +/// path: Game.Command.Help +/// +public class HelpTextEN +{ + public string Desc => "Show help information"; + public string Usage => + "Usage: /help\n" + + "Usage: /help [cmd]"; + public string Commands => "Commands: "; + public string CommandUsage => "Usage: "; + public string CommandPermission => "Level Permission For Access: "; + public string CommandAlias => "Command Alias:"; +} + +/// +/// path: Game.Command.Valk +/// +public class ValkTextEN +{ + public string Desc => "Set attributes for owned characters\n" + + "Note: -1 means all owned characters\n"; + + public string Usage => + "Usage: /valk add [ValkID/-1] l s\n\n" + + "Usage: /valk level [ValkID/-1] [Level]\n\n" + + "Usage: /valk star [ValkID/-1] [Star]\n\n" + + "Usage: /valk skill [ValkID/-1] for max skill level"; + + public string ValkNotFound => "Character does not exist!"; + public string ValkAddedAll => "Granted all characters to player!"; + public string ValkAdded => "Granted character {0} to player!"; + public string ValkSetLevelAll => "Set all characters to level {0}!"; + public string ValkSetLevel => "Set character {0} to level {1}!"; + public string ValkSetStarAll => "Set all characters' Resonance to {0}!"; + public string ValkSetStar => "Set character {0}'s Resonance to {1}!"; + public string ValkSetSkillLevelAll => "Set all characters' skill levels to max!"; + public string ValkSetSkillLevel => "Set character {0}'s skill levels to max!"; +} + +/// +/// path: Game.Command.GiveAll +/// +public class GiveAllTextEN +{ + public string Desc => "Give all items of specified type\n" + + "weapon,stigmata"; + + public string Usage => + "Usage: /giveall weapon\n\n" + + "Usage: /giveall stigmata\n\n" + + "Usage: /giveall material\n\n" + + "Usage: /giveall dress\n"; + + public string GiveAllItems => "Granted all {0}"; +} + +/// +/// path: Game.Command.Elf +/// +public class ElfTextEN +{ + public string Desc => "Set attributes for owned elfs\n" + + "Note: -1 means all owned elfs\n"; + + public string Usage => + "Usage: /elf add [ElfID/-1] l s\n\n"; + + public string ElfNotFound => "Elf does not exist!"; + public string ElfAddedAll => "Granted all Elfs to player!"; + public string ElfAdded => "Granted Elf {0} to player!"; + public string ElfSetLevelAll => "Set all Elfs to level {0}!"; + public string ElfSetLevel => "Set Elf {0} to level {1}!"; + public string ElfSetStarAll => "Set all Elf's Resonance to {0}!"; + public string ElfSetStar => "Set Elf {0}'s Resonance to {1}!"; +} + +/// +/// path: Game.Command.Abyss +/// +public class AbyssTextEN +{ + public string Desc => "Set abyss disturbance,bracket,site \n"; + + public string Usage => + "Usage: /abyss bracket [1-9]\n\n" + + "Usage: /abyss temp [value]\n\n" + + "Usage: /abyss site [siteId]\n"; + + public string Success => "Success set {0}"; + public string AreaNotFound => "SiteId Not Found"; +} + +/// +/// path: Game.Command.Endless +/// +public class EndlessTextEN +{ + public string Desc => "Set Memorial Arena boss \n"; + + public string Usage => + "Usage: /endless [bossid1] [bossid2] [bossid3]\n\n" + + "/endless 1001 1002 1003"; + + public string Success => "Success set Memorial Arena Boss"; + public string NotFound => "BossId Not Found"; +} + +#endregion + +#endregion \ No newline at end of file diff --git a/Common/Internationalization/PluginLanguageAttribute.cs b/Common/Internationalization/PluginLanguageAttribute.cs new file mode 100644 index 0000000..cde6528 --- /dev/null +++ b/Common/Internationalization/PluginLanguageAttribute.cs @@ -0,0 +1,9 @@ +using MikuSB.Enums.Language; + +namespace MikuSB.Internationalization; + +[AttributeUsage(AttributeTargets.Class)] +public class PluginLanguageAttribute(ProgramLanguageTypeEnum languageType) : Attribute +{ + public ProgramLanguageTypeEnum LanguageType { get; } = languageType; +} \ No newline at end of file diff --git a/Common/Util/ConfigManager.cs b/Common/Util/ConfigManager.cs new file mode 100644 index 0000000..70e72bf --- /dev/null +++ b/Common/Util/ConfigManager.cs @@ -0,0 +1,99 @@ +using MikuSB.Configuration; +using MikuSB.Internationalization; +using Newtonsoft.Json; +using MikuSB.Util.Extensions; + +namespace MikuSB.Util; + +public static class ConfigManager +{ + public static readonly Logger Logger = new("ConfigManager"); + public static ConfigContainer Config { get; private set; } = new(); + private static readonly string ConfigFilePath = Config.Path.ConfigPath + "/Config.json"; + public static HotfixContainer Hotfix { get; private set; } = new(); + private static readonly string HotfixFilePath = Config.Path.ConfigPath + "/Hotfix.json"; + + public static void LoadConfig() + { + LoadConfigData(); + //LoadHotfixData(); + } + + private static void LoadConfigData() + { + var file = new FileInfo(ConfigFilePath); + if (!file.Exists) + { + Config = new() + { + ServerOption = + { + Language = Extensions.Extensions.GetCurrentLanguage() + } + }; + + Logger.Info("Current Language is " + Config.ServerOption.Language); + SaveData(Config, ConfigFilePath); + } + + using (var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream)) + { + var json = reader.ReadToEnd(); + Config = JsonConvert.DeserializeObject(json)!; + } + + SaveData(Config, ConfigFilePath); + } + + private static void LoadHotfixData() + { + var file = new FileInfo(HotfixFilePath); + + // Generate all necessary versions + var verList = Extensions.Extensions.GetSupportVersions(); + + Logger.Info(I18NManager.Translate("Server.ServerInfo.CurrentVersion", + verList.Aggregate((current, next) => $"{current}, {next}"))); + + if (!file.Exists) + { + Hotfix = new HotfixContainer(); + SaveData(Hotfix, HotfixFilePath); + file.Refresh(); + } + + using (var stream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream)) + { + var json = reader.ReadToEnd(); + Hotfix = JsonConvert.DeserializeObject(json)!; + } + + foreach (var version in verList) + if (!Hotfix.Hotfixes.TryGetValue(version, out var _)) + Hotfix.Hotfixes[version] = new(); + + SaveData(Hotfix, HotfixFilePath); + } + + private static void SaveData(object data, string path) + { + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + using var writer = new StreamWriter(stream); + writer.Write(json); + } + + public static void InitDirectories() + { + foreach (var property in Config.Path.GetType().GetProperties()) + { + var dir = property.GetValue(Config.Path)?.ToString(); + + if (!string.IsNullOrEmpty(dir)) + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + } + } +} \ No newline at end of file diff --git a/Common/Util/Crpyto/DispatchEncryption.cs b/Common/Util/Crpyto/DispatchEncryption.cs new file mode 100644 index 0000000..466b95c --- /dev/null +++ b/Common/Util/Crpyto/DispatchEncryption.cs @@ -0,0 +1,35 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace MikuSB.Util.Crypto; + +public static class DispatchEncryption +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public static string? EncryptDispatchContent(string version, object? data) + { + if (!ConfigManager.Hotfix.AesKeys.TryGetValue(version, out var aesKey)) + return null; + + var serializedData = JsonSerializer.Serialize(data, JsonSerializerOptions); + var keyBytes = aesKey.Split(' ') + .Select(b => Convert.ToByte(b, 16)) + .ToArray(); + + using var aes = Aes.Create(); + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.PKCS7; + aes.Key = keyBytes; + + var encryptor = aes.CreateEncryptor(); + var dataBytes = Encoding.UTF8.GetBytes(serializedData); + var encryptedBytes = encryptor.TransformFinalBlock(dataBytes, 0, dataBytes.Length); + + return Convert.ToBase64String(encryptedBytes); + } +} \ No newline at end of file diff --git a/Common/Util/DateTime.cs b/Common/Util/DateTime.cs new file mode 100644 index 0000000..61308d0 --- /dev/null +++ b/Common/Util/DateTime.cs @@ -0,0 +1,14 @@ + +namespace MikuSB.Util; + + +public static class DateTimeExtensions +{ + private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public static long ToUnixTimestampMilliseconds(this DateTime dateTime) + { + return (long)(dateTime - UnixEpoch).TotalMilliseconds; + } + +} diff --git a/Common/Util/Extensions/Extensions.cs b/Common/Util/Extensions/Extensions.cs new file mode 100644 index 0000000..a7ed27b --- /dev/null +++ b/Common/Util/Extensions/Extensions.cs @@ -0,0 +1,232 @@ +using MikuSB.Proto; +using Newtonsoft.Json; +using System.Buffers.Binary; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace MikuSB.Util.Extensions; + +public static partial class Extensions +{ + #region Regex + + [GeneratedRegex(@"CN|OS|BETA|PROD|CECREATION|Android|Win|iOS")] + public static partial Regex VersionRegex(); + + [GeneratedRegex(@"(?<=Avatar_)(.*?)(?=_Config)")] + public static partial Regex AvatarConfigRegex(); + + [GeneratedRegex(@"(?<=Avatar_RogueBattleevent)(.*?)(?=_Config.json)")] + public static partial Regex BattleEventDataRegex(); + + [GeneratedRegex(@"coin(\d+)tier")] + public static partial Regex ProductRegex(); + + #endregion + + public static string GetCurrentLanguage() + { + var uiCulture = CultureInfo.CurrentUICulture; + return uiCulture.Name switch + { + "zh-CN" => "CHS", + "zh-TW" => "CHT", + "ja-JP" => "JP", + _ => "EN" + }; + } + + public static List GetSupportVersions() + { + var verList = new List(); + if (GameConstants.GAME_VERSION[^1] == '5') + for (var i = 1; i < 6; i++) + verList.Add(GameConstants.GAME_VERSION + i.ToString()); + else + verList.Add(GameConstants.GAME_VERSION); + + return verList; + } + + public static T RandomElement(this List values) + { + var index = new Random().Next(values.Count); + return values[index]; + } + + public static string RandomKey(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var random = new Random(); + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } + + public static ICollection Clone(this ICollection values) + { + List list = [.. values]; + + return list; + } + + public static int RandomInt(int from, int to) + { + return new Random().Next(from, to); + } + + public static string GetSha256Hash(string input) + { + byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + var builder = new StringBuilder(); + for (int i = 0; i < bytes.Length; i++) builder.Append(bytes[i].ToString("x2")); + return builder.ToString(); + } + + public static void SafeAdd(this List list, T item) + { + if (!list.Contains(item)) list.Add(item); + } + + public static void SafeAddRange(this List list, List item) + { + foreach (var i in item) list.SafeAdd(i); + } + + public static long GetUnixSec() + { + return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + } + + public static long ToUnixSec(this DateTime dt) + { + return new DateTimeOffset(dt).ToUnixTimeSeconds(); + } + + public static long GetUnixMs() + { + return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + public static string ToArrayString(this List list) + { + return list.JoinFormat(", ", ""); + } + + public static string ToJsonString(this Dictionary dic) where TK : notnull + { + return JsonConvert.SerializeObject(dic); + } + + public static byte[] StringToByteArray(string hex) + { + if (hex.Length % 2 == 1) + throw new Exception("The binary key cannot have an odd number of digits"); + + byte[] arr = new byte[hex.Length >> 1]; + + for (int i = 0; i < hex.Length >> 1; ++i) + { + arr[i] = (byte)((GetHexVal(hex[i << 1]) << 4) + (GetHexVal(hex[(i << 1) + 1]))); + } + + return arr; + } + + public static int GetHexVal(char hex) + { + int val = (int)hex; + //For uppercase A-F letters: + //return val - (val < 58 ? 48 : 55); + //For lowercase a-f letters: + //return val - (val < 58 ? 48 : 87); + //Or the two combined, but a bit slower: + return val - (val < 58 ? 48 : (val < 97 ? 55 : 87)); + } + + #region Kcp Utils + + public static string JoinFormat(this IEnumerable list, string separator, + string formatString) + { + formatString = string.IsNullOrWhiteSpace(formatString) ? "{0}" : formatString; + return string.Join(separator, + list.Select(item => string.Format(formatString, item))); + } + + public static void WriteConvID(this BinaryWriter bw, long convId) + { + //bw.Write(convId); + bw.Write((int)(convId >> 32)); + bw.Write((int)(convId & 0xFFFFFFFF)); + } + + public static long GetNextAvailableIndex(this SortedList sortedList) + { + long key = 1; + long count = sortedList.Count; + long counter = 0; + do + { + if (count == 0) break; + var nextKeyInList = sortedList.Keys.ElementAt((Index)counter++); + if (key != nextKeyInList) break; + key = nextKeyInList + 1; + } while (count != 1 && counter != count && key == sortedList.Keys.ElementAt((Index)counter)); + + return key; + } + + public static long AddNext(this SortedList sortedList, T item) + { + var key = sortedList.GetNextAvailableIndex(); + sortedList.Add(key, item); + return key; + } + + public static int ReadInt32BE(this BinaryReader br) + { + return BinaryPrimitives.ReadInt32BigEndian(br.ReadBytes(sizeof(int))); + } + + public static uint ReadUInt32BE(this BinaryReader br) + { + return BinaryPrimitives.ReadUInt32BigEndian(br.ReadBytes(sizeof(uint))); + } + + public static ushort ReadUInt16BE(this BinaryReader br) + { + return BinaryPrimitives.ReadUInt16BigEndian(br.ReadBytes(sizeof(ushort))); + } + + public static void WriteUInt16BE(this BinaryWriter bw, ushort value) + { + Span data = stackalloc byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16BigEndian(data, value); + bw.Write(data); + } + + public static void WriteInt32BE(this BinaryWriter bw, int value) + { + Span data = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32BigEndian(data, value); + bw.Write(data); + } + + public static void WriteUInt32BE(this BinaryWriter bw, uint value) + { + Span data = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32BigEndian(data, value); + bw.Write(data); + } + + public static void WriteUInt64BE(this BinaryWriter bw, ulong value) + { + Span data = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64BigEndian(data, value); + bw.Write(data); + } + + #endregion +} \ No newline at end of file diff --git a/Common/Util/GameConstants.cs b/Common/Util/GameConstants.cs new file mode 100644 index 0000000..726a724 --- /dev/null +++ b/Common/Util/GameConstants.cs @@ -0,0 +1,11 @@ +namespace MikuSB.Util; + +public static class GameConstants +{ + public const string GAME_VERSION = "BETA V1.0"; + public const int MAX_STAMINA = 300; + public const int STAMINA_RECOVERY_TIME = 360; // 6 minutes + public const int STAMINA_RESERVE_RECOVERY_TIME = 1080; // 18 minutes + public const int INVENTORY_MAX_EQUIPMENT = 1000; + public const int MAX_LINEUP_COUNT = 9; +} \ No newline at end of file diff --git a/Common/Util/Guid64.cs b/Common/Util/Guid64.cs new file mode 100644 index 0000000..062a1ea --- /dev/null +++ b/Common/Util/Guid64.cs @@ -0,0 +1,11 @@ +namespace MikuSB.Util; + +public static class Guid64 +{ + public static ulong NewGuid64() + { + byte[] guidBytes = Guid.NewGuid().ToByteArray(); + return (ulong)BitConverter.ToUInt32(guidBytes, 0); + } +} + diff --git a/Common/Util/IConsole.cs b/Common/Util/IConsole.cs new file mode 100644 index 0000000..ac0c0e9 --- /dev/null +++ b/Common/Util/IConsole.cs @@ -0,0 +1,185 @@ +using Kodnix.Character; + +namespace MikuSB.Util; + +public class IConsole +{ + public static readonly string PrefixContent = "[MikuSB]> "; + public static readonly string Prefix = $"\u001b[38;2;255;192;203m{PrefixContent}\u001b[0m"; + private static readonly int HistoryMaxCount = 10; + + public static List Input { get; set; } = []; + private static int CursorIndex { get; set; } = 0; + private static readonly List InputHistory = []; + private static int HistoryIndex = -1; + + public static event Action? OnConsoleExcuteCommand; + + public static void InitConsole() + { + Console.Title = ConfigManager.Config.GameServer.GameServerName; + } + + public static int GetWidth(string str) + => str.ToCharArray().Sum(EastAsianWidth.GetLength); + + public static void RedrawInput(List input, bool hasPrefix = true) + => RedrawInput(new string([.. input]), hasPrefix); + + public static void RedrawInput(string input, bool hasPrefix = true) + { + var length = GetWidth(input); + if (hasPrefix) + { + input = Prefix + input; + length += GetWidth(PrefixContent); + } + + if (Console.GetCursorPosition().Left > 0) + Console.SetCursorPosition(0, Console.CursorTop); + + Console.Write(input + new string(' ', Console.BufferWidth - length)); + Console.SetCursorPosition(length, Console.CursorTop); + } + + #region Handlers + + public static void HandleEnter() + { + var input = new string([.. Input]); + if (string.IsNullOrWhiteSpace(input)) return; + + // New line + Console.WriteLine(); + Input = []; + CursorIndex = 0; + if (InputHistory.Count >= HistoryMaxCount) + InputHistory.RemoveAt(0); + InputHistory.Add(input); + HistoryIndex = InputHistory.Count; + + // Handle command + if (input.StartsWith('/')) input = input[1..].Trim(); + OnConsoleExcuteCommand?.Invoke(input); + } + + public static void HandleBackspace() + { + if (CursorIndex <= 0) return; + CursorIndex--; + var targetWidth = GetWidth(Input[CursorIndex].ToString()); + Input.RemoveAt(CursorIndex); + + var (left, _) = Console.GetCursorPosition(); + Console.SetCursorPosition(left - targetWidth, Console.CursorTop); + var remain = new string([.. Input.Skip(CursorIndex)]); + Console.Write(remain + new string(' ', targetWidth)); + Console.SetCursorPosition(left - targetWidth, Console.CursorTop); + } + + public static void HandleUpArrow() + { + if (InputHistory.Count == 0) return; + + if (HistoryIndex > 0) + { + HistoryIndex--; + var history = InputHistory[HistoryIndex]; + Input = [.. history]; + CursorIndex = Input.Count; + RedrawInput(Input); + } + } + + public static void HandleDownArrow() + { + if (HistoryIndex >= InputHistory.Count) return; + + HistoryIndex++; + if (HistoryIndex >= InputHistory.Count) + { + HistoryIndex = InputHistory.Count; + Input = []; + CursorIndex = 0; + } + else + { + var history = InputHistory[HistoryIndex]; + Input = [.. history]; + CursorIndex = Input.Count; + } + RedrawInput(Input); + } + + public static void HandleLeftArrow() + { + if (CursorIndex <= 0) return; + + var (left, _) = Console.GetCursorPosition(); + CursorIndex--; + Console.SetCursorPosition(left - GetWidth(Input[CursorIndex].ToString()), Console.CursorTop); + } + + public static void HandleRightArrow() + { + if (CursorIndex >= Input.Count) return; + + var (left, _) = Console.GetCursorPosition(); + CursorIndex++; + Console.SetCursorPosition(left + GetWidth(Input[CursorIndex - 1].ToString()), Console.CursorTop); + } + + public static void HandleInput(ConsoleKeyInfo keyInfo) + { + if (char.IsControl(keyInfo.KeyChar)) return; + if (Input.Count >= (Console.BufferWidth - PrefixContent.Length)) return; + HandleInput(keyInfo.KeyChar); + } + + public static void HandleInput(char keyChar) + { + Input.Insert(CursorIndex, keyChar); + CursorIndex++; + + var (left, _) = Console.GetCursorPosition(); + Console.Write(new string([.. Input.Skip(CursorIndex - 1)])); + Console.SetCursorPosition(left + GetWidth(keyChar.ToString()), Console.CursorTop); + } + + #endregion + + public static string ListenConsole() + { + while (true) + { + ConsoleKeyInfo keyInfo; + try { keyInfo = Console.ReadKey(true); } + catch (InvalidOperationException) { continue; } + + switch (keyInfo.Key) + { + case ConsoleKey.Enter: + HandleEnter(); + break; + case ConsoleKey.Backspace: + HandleBackspace(); + break; + case ConsoleKey.LeftArrow: + HandleLeftArrow(); + break; + case ConsoleKey.RightArrow: + HandleRightArrow(); + break; + case ConsoleKey.UpArrow: + HandleUpArrow(); + break; + case ConsoleKey.DownArrow: + HandleDownArrow(); + break; + default: + HandleInput(keyInfo); + break; + } + } + } +} \ No newline at end of file diff --git a/Common/Util/Logger.cs b/Common/Util/Logger.cs new file mode 100644 index 0000000..5a60b49 --- /dev/null +++ b/Common/Util/Logger.cs @@ -0,0 +1,109 @@ +using Spectre.Console; +using System.Diagnostics; + +namespace MikuSB.Util; + +public class Logger(string moduleName) +{ + private static FileInfo? LogFile; + private static readonly object _lock = new(); + private readonly string ModuleName = moduleName; + + public void Log(string message, LoggerLevel level) + { + lock (_lock) + { + var savedInput = IConsole.Input.ToList(); // Copy + IConsole.RedrawInput("", false); + AnsiConsole.MarkupLine($"[[[bold deepskyblue3_1]{DateTime.Now:HH:mm:ss}[/]]] " + + $"[[[gray]{ModuleName}[/]]] [[[{(ConsoleColor)level}]{level}[/]]] " + + $"{message.Replace("[", "[[").Replace("]", "]]")}"); + IConsole.RedrawInput(savedInput); + + var logMessage = $"[{DateTime.Now:HH:mm:ss}] [{ModuleName}] [{level}] {message}"; + WriteToFile(logMessage); + } + } + + public void Info(string message, Exception? e = null) + { + Log(message, LoggerLevel.INFO); + if (e != null) + { + Log(e.Message, LoggerLevel.INFO); + Log(e.StackTrace!, LoggerLevel.INFO); + } + } + + public void Warn(string message, Exception? e = null) + { + Log(message, LoggerLevel.WARN); + if (e != null) + { + Log(e.Message, LoggerLevel.WARN); + Log(e.StackTrace!, LoggerLevel.WARN); + } + } + + public void Error(string message, Exception? e = null) + { + Log(message, LoggerLevel.ERROR); + if (e != null) + { + Log(e.Message, LoggerLevel.ERROR); + Log(e.StackTrace!, LoggerLevel.ERROR); + } + } + + public void Fatal(string message, Exception? e = null) + { + Log(message, LoggerLevel.FATAL); + if (e != null) + { + Log(e.Message, LoggerLevel.FATAL); + Log(e.StackTrace!, LoggerLevel.FATAL); + } + } + + public void Debug(string message, Exception? e = null) + { + Log(message, LoggerLevel.DEBUG); + if (e != null) + { + Log(e.Message, LoggerLevel.DEBUG); + Log(e.StackTrace!, LoggerLevel.DEBUG); + } + } + + public static void SetLogFile(FileInfo file) + { + LogFile = file; + } + + public static void WriteToFile(string message) + { + try + { + if (LogFile == null) throw new Exception("LogFile is not set"); + using var sw = LogFile.AppendText(); + sw.WriteLine(message); + } + catch + { + } + } + + public static Logger GetByClassName() + { + return new Logger(new StackTrace().GetFrame(1)?.GetMethod()?.ReflectedType?.Name ?? ""); + } +} + +public enum LoggerLevel +{ + INFO = ConsoleColor.Cyan, + WARN = ConsoleColor.Yellow, + ERROR = ConsoleColor.Red, + FATAL = ConsoleColor.DarkRed, + DEBUG = ConsoleColor.Blue +} diff --git a/Common/Util/LoggingMiddleware.cs b/Common/Util/LoggingMiddleware.cs new file mode 100644 index 0000000..5e5563f --- /dev/null +++ b/Common/Util/LoggingMiddleware.cs @@ -0,0 +1,34 @@ +using MikuSB.Util; +using Microsoft.AspNetCore.Http; + +namespace MikuSB.SdkServer.Utils; + +public class RequestLoggingMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, Logger logger) + { + var request = context.Request; + var method = request.Method; + var path = request.Path + request.QueryString; + + await next(context); + + var statusCode = context.Response.StatusCode; + + if (path.StartsWith("/report") || path.Contains("/log/") || path == "/alive") + return; + + if (statusCode == 200) + { + logger.Info($"{method} {path} => {statusCode}"); + } + else if (statusCode == 404) + { + logger.Warn($"{method} {path} => {statusCode}"); + } + else + { + logger.Error($"{method} {path} => {statusCode}"); + } + } +} \ No newline at end of file diff --git a/Common/Util/Position.cs b/Common/Util/Position.cs new file mode 100644 index 0000000..dbfe891 --- /dev/null +++ b/Common/Util/Position.cs @@ -0,0 +1,129 @@ +namespace MikuSB.Common.Util; + +public class Position +{ + public Position(float x, float y, float z) + { + X = x; + Y = y; + Z = z; + } + + //public Position(Proto.Position position) + //{ + // X = position.X; + // Y = position.Y; + // Z = position.Z; + //} + + public Position() + { + X = 0; + Y = 0; + Z = 0; + } + + public Position(Position position) + { + X = position.X; + Y = position.Y; + Z = position.Z; + } + + public float X { get; set; } + public float Y { get; set; } + public float Z { get; set; } + + public void Set(float x, float y, float z) + { + X = x; + Y = y; + Z = z; + } + + public void Set(Position position) + { + X = position.X; + Y = position.Y; + Z = position.Z; + } + + //public void Set(Vector vector) + //{ + // X = vector.X; + // Y = vector.Y; + // Z = vector.Z; + //} + + public void Add(float x, float y, float z) + { + X += x; + Y += y; + Z += z; + } + + public void Add(Position position) + { + X += position.X; + Y += position.Y; + Z += position.Z; + } + + public void Sub(float x, float y, float z) + { + X -= x; + Y -= y; + Z -= z; + } + + public void Sub(Position position) + { + X -= position.X; + Y -= position.Y; + Z -= position.Z; + } + + public void Mul(float x, float y, float z) + { + X *= x; + Y *= y; + Z *= z; + } + + public void Mul(Position position) + { + X *= position.X; + Y *= position.Y; + Z *= position.Z; + } + + public void Div(float x, float y, float z) + { + X /= x; + Y /= y; + Z /= z; + } + + public void Div(Position position) + { + X /= position.X; + Y /= position.Y; + Z /= position.Z; + } + + public double Distance(Position position) + { + return Math.Sqrt((X - position.X) * (X - position.X) + (Y - position.Y) * (Y - position.Y) + + (Z - position.Z) * (Z - position.Z)); + } + + //public Proto.Position ToProto() + //{ + // return new Proto.Position + // { + // X = (int)X, + // Y = (int)Y, + // Z = (int)Z + // }; + //} +} \ No newline at end of file diff --git a/Common/Util/Security/Crypto.cs b/Common/Util/Security/Crypto.cs new file mode 100644 index 0000000..4328b40 --- /dev/null +++ b/Common/Util/Security/Crypto.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MikuSB.Util.Security; + +public class Crypto +{ + private static readonly Random SecureRandom = new(); + + // Simple way to create a unique session key + public static string CreateSessionKey(string accountUid) + { + var random = new byte[64]; + SecureRandom.NextBytes(random); + + var temp = accountUid + "." + DateTime.Now.Ticks + "." + SecureRandom; + + try + { + var bytes = SHA512.HashData(Encoding.UTF8.GetBytes(temp)); + return Convert.ToBase64String(bytes); + } + catch + { + var bytes = SHA512.HashData(Encoding.UTF8.GetBytes(temp)); + return Convert.ToBase64String(bytes); + } + } +} \ No newline at end of file diff --git a/Config/Config.json b/Config/Config.json new file mode 100644 index 0000000..b601499 --- /dev/null +++ b/Config/Config.json @@ -0,0 +1,42 @@ +{ + "HttpServer": { + "BindAddress": "0.0.0.0", + "PublicAddress": "127.0.0.1", + "Port": 21500 + }, + "GameServer": { + "BindAddress": "0.0.0.0", + "PublicAddress": "127.0.0.1", + "Port": 21000, + "KcpAliveMs": 45000, + "DatabaseName": "Miku.db", + "GameServerId": "MikuSB", + "GameServerName": "MikuSB" + }, + "Path": { + "ResourcePath": "Resources", + "ConfigPath": "Config", + "DatabasePath": "Config/Database", + "HandbookPath": "Config/Handbook", + "LogPath": "Config/Logs", + "DataPath": "Config/Data" + }, + "ServerOption": { + "Language": "EN", + "FallbackLanguage": "EN", + "DefaultPermissions": [ + "Admin" + ], + "ServerProfile": { + "Name": "Miku-chan", + "Uid": 80 + }, + "AutoCreateUser": true, + "SavePersonalDebugFile": false, + "AutoSendResponseWhenNoHandler": true, + "EnableDebug": true, + "DebugMessage": true, + "DebugDetailMessage": true, + "DebugNoHandlerPacket": true + } +} \ No newline at end of file diff --git a/Config/Hotfix.json b/Config/Hotfix.json new file mode 100644 index 0000000..00f3859 --- /dev/null +++ b/Config/Hotfix.json @@ -0,0 +1,51 @@ +{ + "UseLocalCache": false, + "Hotfixes": { + "BETA V1.0": { + "Asb": { + "Android": { + "EnableTime": 0, + "Revision": "", + "Suffix": "" + }, + "Iphone": { + "EnableTime": 0, + "Revision": "", + "Suffix": "" + }, + "Pc": { + "EnableTime": 0, + "Revision": "", + "Suffix": "" + } + }, + "AsbPreDownload": { + "Android": { + "EncryptKey": "", + "EnableTime": 0, + "Revision": "", + "Suffix": "" + }, + "Iphone": { + "EncryptKey": "", + "EnableTime": 0, + "Revision": "", + "Suffix": "" + } + }, + "Audio": { + "Platform": {}, + "Revision": 0 + }, + "AudioPreDownload": { + "EnableTime": 0, + "Platform": {}, + "Revision": 0 + }, + "VideoEncrypt": { + "FileName": "" + } + } + }, + "AesKeys": {} +} \ No newline at end of file diff --git a/GameServer/Command/CommandArg.cs b/GameServer/Command/CommandArg.cs new file mode 100644 index 0000000..e9cd9c1 --- /dev/null +++ b/GameServer/Command/CommandArg.cs @@ -0,0 +1,79 @@ +using MikuSB.Database.Account; +using MikuSB.GameServer.Server; +using MikuSB.Internationalization; + +namespace MikuSB.GameServer.Command; + +public class CommandArg +{ + public string RawArg { get; } = ""; + public List Args { get; } = []; + public List Attributes { get; } = []; + public ICommandSender Sender { get; } + public int TargetUid { get; set; } = 0; + public Connection? Target { get; set; } + + public CommandArg(string rawArg, ICommandSender sender) + { + Sender = sender; + RawArg = rawArg; + foreach (var arg in rawArg.Split(' ')) + { + if (string.IsNullOrEmpty(arg)) continue; + Args.Add(arg); + } + } + + public async ValueTask SendMsg(string msg) + { + await Sender.SendMsg(msg); + } + + public int GetInt(int index) + { + if (Args.Count <= index) return 0; + if (int.TryParse(Args[index], out var res)) + return res; + return 0; + } + + public async ValueTask GetOption(char pre, string def = "1") + { + var opStr = Args.FirstOrDefault(x => x[0] == pre)?[1..] ?? def; + if (!int.TryParse(opStr, out var op)) + { + await SendMsg(I18NManager.Translate("Game.Command.Notice.InvalidArguments")); + return null; + } + return op; + } + + public async ValueTask CheckArgCnt(int start, int? end = null) + { + end ??= start; + if (Args.Count >= start && Args.Count <= end) return true; + await SendMsg(I18NManager.Translate("Game.Command.Notice.InvalidArguments")); + return false; + } + + public async ValueTask CheckTarget() + { + if (AccountData.GetAccountByUid(TargetUid) == null) + { + await SendMsg(I18NManager.Translate("Game.Command.Notice.PlayerNotFound")); + return false; + } + return true; + } + + public async ValueTask CheckOnlineTarget(bool sendMsg = true) + { + if (Target == null) + { + if (sendMsg) + await SendMsg(I18NManager.Translate("Game.Command.Notice.PlayerNotFound")); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/GameServer/Command/CommandAttribute.cs b/GameServer/Command/CommandAttribute.cs new file mode 100644 index 0000000..6d2411f --- /dev/null +++ b/GameServer/Command/CommandAttribute.cs @@ -0,0 +1,25 @@ +using MikuSB.Enums.Player; + +namespace MikuSB.GameServer.Command; + +[AttributeUsage(AttributeTargets.Class)] +public class CommandInfoAttribute( + string name, string desc, string usage, string[] alias, PermEnum[] perm) : Attribute +{ + public string Name { get; } = name; + public string Description { get; } = desc; + public string Usage { get; } = usage; + public PermEnum[] Perm { get; } = perm; + public string[] Alias { get; } = alias; +} + +[AttributeUsage(AttributeTargets.Method)] +public class CommandDefaultAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method)] +public class CommandMethodAttribute(string method) : Attribute +{ + public string MethodName { get; } = method; +} \ No newline at end of file diff --git a/GameServer/Command/CommandExecutor.cs b/GameServer/Command/CommandExecutor.cs new file mode 100644 index 0000000..81e7f5e --- /dev/null +++ b/GameServer/Command/CommandExecutor.cs @@ -0,0 +1,19 @@ + +namespace MikuSB.GameServer.Command; + +public static class CommandExecutor +{ + public delegate void RunCommand(ICommandSender sender, string cmd); + + public static event RunCommand? OnRunCommand; + + public static void ExecuteCommand(ICommandSender sender, string cmd) + { + OnRunCommand?.Invoke(sender, cmd); + } + + public static void ConsoleExcuteCommand(string input) + { + CommandManager.HandleCommand(input, new ConsoleCommandSender(CommandManager.Logger)); + } +} \ No newline at end of file diff --git a/GameServer/Command/CommandInterface.cs b/GameServer/Command/CommandInterface.cs new file mode 100644 index 0000000..35d325a --- /dev/null +++ b/GameServer/Command/CommandInterface.cs @@ -0,0 +1,3 @@ +namespace MikuSB.GameServer.Command; + +public interface ICommands; \ No newline at end of file diff --git a/GameServer/Command/CommandManager.cs b/GameServer/Command/CommandManager.cs new file mode 100644 index 0000000..4d9fc34 --- /dev/null +++ b/GameServer/Command/CommandManager.cs @@ -0,0 +1,126 @@ +using MikuSB.Database.Account; +using MikuSB.Enums.Player; +using MikuSB.GameServer.Server; +using MikuSB.Internationalization; +using MikuSB.TcpSharp; +using MikuSB.Util; +using System.Reflection; + +namespace MikuSB.GameServer.Command; + +public class CommandManager +{ + public static Logger Logger { get; } = new("CommandManager"); + + public static Dictionary Commands { get; } = []; + public static Dictionary CommandInfo { get; } = []; + public static Dictionary CommandAlias { get; } = []; // + + public static void RegisterCommands() + { + foreach (var type in Assembly.GetExecutingAssembly().GetTypes()) + if (typeof(ICommands).IsAssignableFrom(type) && !type.IsAbstract) + RegisterCommand(type); + + Logger.Info(I18NManager.Translate("Server.ServerInfo.RegisterItem", Commands.Count.ToString(), + I18NManager.Translate("Word.Command"))); + } + + public static void RegisterCommand(Type type) + { + var attr = type.GetCustomAttribute(); + if (attr == null) return; + var instance = Activator.CreateInstance(type); + if (instance is not ICommands command) return; + Commands.Add(attr.Name, command); + CommandInfo.Add(attr.Name, attr); + + // register alias + foreach (var alias in attr.Alias) // add alias + CommandAlias.Add(alias, attr.Name); + } + + public static async void HandleCommand(string input, ICommandSender sender) + { + try + { + var argInfo = new CommandArg(input, sender); + var target = sender.GetSender(); + + foreach (var arg in argInfo.Args.ToList()) // Copy + { + switch (arg[0]) + { + case '-': + argInfo.Attributes.Add(arg[1..]); + break; + case '@': + _ = int.TryParse(arg[1..], out target); + argInfo.Args.Remove(arg); + break; + } + } + argInfo.TargetUid = target; + if (SocketListener.Connections.Values.ToList().Find(item => + (item as Connection)?.Player?.Uid == target) is Connection con) + argInfo.Target = con; + + // find register cmd + var cmdName = argInfo.Args[0]; + if (CommandAlias.TryGetValue(cmdName, out var fullName)) cmdName = fullName; + if (!Commands.TryGetValue(cmdName, out var command)) + { + await sender.SendMsg(I18NManager.Translate("Game.Command.Notice.CommandNotFound")); + return; + } + argInfo.Args.RemoveAt(0); + var cmdInfo = CommandInfo[cmdName]; + + // Check cmd perms + if (!AccountData.HasPerm(cmdInfo.Perm, sender.GetSender())) + { + await sender.SendMsg(I18NManager.Translate("Game.Command.Notice.NoPermission")); + return; + } + if (argInfo.Target?.Player?.Uid != sender.GetSender() && !AccountData.HasPerm([PermEnum.Other], sender.GetSender())) + { + await sender.SendMsg(I18NManager.Translate("Game.Command.Notice.NoPermission")); + return; + } + + // find CommandMethodAttribute + var isFound = false; + foreach (var methodInfo in command.GetType().GetMethods()) + { + var attr = methodInfo.GetCustomAttribute(); + if (attr == null) continue; + if (argInfo.Args.Count > 0 && attr.MethodName == argInfo.Args[0]) + { + argInfo.Args.RemoveAt(0); + isFound = true; + methodInfo.Invoke(command, [argInfo]); + break; + } + } + if (isFound) return; + + // find CommandDefaultAttribute + foreach (var methodInfo in command.GetType().GetMethods()) + { + var attr = methodInfo.GetCustomAttribute(); + if (attr == null) continue; + isFound = true; + methodInfo.Invoke(command, [argInfo]); + break; + } + if (isFound) return; + + // failed to find method + await sender.SendMsg(I18NManager.Translate(cmdInfo.Usage)); + } + catch (Exception ex) + { + Logger.Error(I18NManager.Translate("Game.Command.Notice.InternalError", ex.ToString())); + } + } +} \ No newline at end of file diff --git a/GameServer/Command/CommandSender.cs b/GameServer/Command/CommandSender.cs new file mode 100644 index 0000000..eba40ac --- /dev/null +++ b/GameServer/Command/CommandSender.cs @@ -0,0 +1,41 @@ +using MikuSB.Enums.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Util; + +namespace MikuSB.GameServer.Command; + +public interface ICommandSender +{ + public ValueTask SendMsg(string msg); + + public int GetSender(); +} + +public class ConsoleCommandSender(Logger logger) : ICommandSender +{ + public async ValueTask SendMsg(string msg) + { + logger.Info(msg); + await Task.CompletedTask; + } + + public int GetSender() + { + return (int)ServerEnum.Console; + } +} + +public class PlayerCommandSender(PlayerInstance player) : ICommandSender +{ + public PlayerInstance Player = player; + + public async ValueTask SendMsg(string msg) + { + // TODO + } + + public int GetSender() + { + return Player.Uid; + } +} \ No newline at end of file diff --git a/GameServer/Command/Commands/CommandHelp.cs b/GameServer/Command/Commands/CommandHelp.cs new file mode 100644 index 0000000..24951b6 --- /dev/null +++ b/GameServer/Command/Commands/CommandHelp.cs @@ -0,0 +1,52 @@ +using MikuSB.Enums.Player; +using MikuSB.Internationalization; +using MikuSB.Util.Extensions; + +namespace MikuSB.GameServer.Command.Commands; + +[CommandInfo("help", "Game.Command.Help.Desc", "Game.Command.Help.Usage", ["h"], [PermEnum.Support, PermEnum.Trial])] +public class CommandHelp : ICommands +{ + [CommandDefault] + public async static ValueTask Help(CommandArg arg) + { + if (arg.Args.Count == 1) + { + var cmd = arg.Args[0]; + if (CommandManager.CommandInfo == null || !CommandManager.CommandInfo.TryGetValue(cmd, out var command)) + { + await arg.SendMsg(I18NManager.Translate("Game.Command.Notice.CommandNotFound")); + return; + } + + var msg = + $"/{command.Name} - {I18NManager.Translate(command.Description)}\n{I18NManager.Translate(command.Usage)}"; + if (command.Alias.Length > 0) + msg += + $"\n{I18NManager.Translate("Game.Command.Help.CommandAlias")} {command.Alias.ToList().ToArrayString()}"; + if (command.Perm != null) + msg += $"\n{I18NManager.Translate("Game.Command.Help.CommandPermission")} {string.Join(", ", command.Perm.Select(perm => perm.ToString()))}"; + + await arg.SendMsg(msg + "\n"); + return; + } + else + { + await arg.SendMsg(I18NManager.Translate("Game.Command.Help.Commands")); + if (CommandManager.CommandInfo == null) return; + + foreach (var command in CommandManager.CommandInfo.Values) + { + var msg = + $"/{command.Name} - {I18NManager.Translate(command.Description)}\n{I18NManager.Translate(command.Usage)}"; + if (command.Alias.Length > 0) + msg += + $"\n{I18NManager.Translate("Game.Command.Help.CommandAlias")} {command.Alias.ToList().ToArrayString()}"; + + if (command.Perm != null) + msg += $"\n{I18NManager.Translate("Game.Command.Help.CommandPermission")} {string.Join(", ", command.Perm.Select(perm => perm.ToString()))}"; + await arg.SendMsg(msg + "\n"); + } + } + } +} \ No newline at end of file diff --git a/GameServer/Game/BasePlayerManager.cs b/GameServer/Game/BasePlayerManager.cs new file mode 100644 index 0000000..0b45023 --- /dev/null +++ b/GameServer/Game/BasePlayerManager.cs @@ -0,0 +1,8 @@ +using MikuSB.GameServer.Game.Player; + +namespace MikuSB.GameServer.Game; + +public class BasePlayerManager(PlayerInstance player) +{ + public PlayerInstance Player { get; private set; } = player; +} \ No newline at end of file diff --git a/GameServer/Game/Player/PlayerInstance.cs b/GameServer/Game/Player/PlayerInstance.cs new file mode 100644 index 0000000..30d3613 --- /dev/null +++ b/GameServer/Game/Player/PlayerInstance.cs @@ -0,0 +1,98 @@ +using MikuSB.Database; +using MikuSB.Database.Account; +using MikuSB.Database.Player; +using MikuSB.GameServer.Server; +using MikuSB.TcpSharp; +using MikuSB.Util.Extensions; + +namespace MikuSB.GameServer.Game.Player; + +public class PlayerInstance(PlayerGameData data) +{ + #region Property + public Connection? Connection { get; set; } + + public static readonly List _playerInstances = []; + public int Uid { get; set; } + public bool Initialized { get; set; } + public bool IsNewPlayer { get; set; } + + #endregion + + #region Data & Manager + + public PlayerGameData Data { get; set; } = data; + + #endregion + + #region Initializers + public PlayerInstance(int uid) : this(new PlayerGameData { Uid = uid }) + { + // new player + IsNewPlayer = true; + Data.Name = AccountData.GetAccountByUid(uid)?.Username; + + DatabaseHelper.CreateInstance(Data); + + var t = Task.Run(async () => + { + await InitialPlayerManager(); + }); + t.Wait(); + + Initialized = true; + + } + private async ValueTask InitialPlayerManager() + { + Uid = Data.Uid; + Data.LastActiveTime = Extensions.GetUnixSec(); + + await Task.CompletedTask; + } + public T InitializeDatabase() where T : BaseDatabaseDataHelper, new() + { + var instance = DatabaseHelper.GetInstanceOrCreateNew(Uid); + return instance!; + } + + #endregion + + #region Network + public async ValueTask OnEnterGame() + { + if (!Initialized) await InitialPlayerManager(); + } + + public async ValueTask OnLogin() + { + _playerInstances.Add(this); + await Task.CompletedTask; + } + + public static PlayerInstance? GetPlayerInstanceByUid(long uid) + => _playerInstances.FirstOrDefault(player => player.Uid == uid); + public void OnLogoutAsync() + { + _playerInstances.Remove(this); + } + public async ValueTask SendPacket(BasePacket packet) + { + if (Connection?.IsOnline == true) await Connection.SendPacket(packet); + } + + #endregion + + #region Actions + public async ValueTask OnHeartBeat() + { + DatabaseHelper.ToSaveUidList.SafeAdd(Uid); + await Task.CompletedTask; + } + + #endregion + + #region Serialization + + #endregion +} \ No newline at end of file diff --git a/GameServer/GameServer.csproj b/GameServer/GameServer.csproj new file mode 100644 index 0000000..6600304 --- /dev/null +++ b/GameServer/GameServer.csproj @@ -0,0 +1,28 @@ + + + + Library + net9.0 + enable + enable + false + MikuSB.GameServer + + true + MikuGameServer + + + + + + + + + + + + + + + + diff --git a/GameServer/Server/Connection.cs b/GameServer/Server/Connection.cs new file mode 100644 index 0000000..b97bf4f --- /dev/null +++ b/GameServer/Server/Connection.cs @@ -0,0 +1,142 @@ +using MikuSB.Enums.Packet; +using MikuSB.GameServer.Game.Player; +using MikuSB.GameServer.Server.Packet; +using MikuSB.TcpSharp; +using MikuSB.Util; +using System.Buffers; +using System.Net; +using System.Net.Sockets; + +namespace MikuSB.GameServer.Server; + +public class Connection(Socket socket, IPEndPoint remote) : SocketConnection(socket, remote) +{ + private static readonly Logger Logger = new("GameServer"); + + public PlayerInstance? Player { get; set; } + + private static readonly HashSet DummyPacketNames = + [ + + ]; + + public override async void Start() + { + Logger.Info($"New connection from {RemoteEndPoint}."); + State = SessionStateEnum.WAITING_FOR_TOKEN; + await ReceiveLoop(); + } + + public override void Stop(bool isServerStop = false) + { + Player?.OnLogoutAsync(); + SocketListener.UnregisterConnection(this); + base.Stop(isServerStop); + } + + public static int GetInt32(byte[] buf, int index) + { + int networkValue = BitConverter.ToInt32(buf, index); + return IPAddress.NetworkToHostOrder((int)networkValue); + } + + protected async Task ReceiveLoop() + { + try + { + var stream = new NetworkStream(Socket, ownsSocket: false); + + while (SocketConnected()) + { + var decodedPacket = await new PacketCodec().ReadPacketAsync(stream, CancelToken.Token); + + if (decodedPacket == null) + { + Logger.Info("Client disconnected"); + break; + } + + switch (decodedPacket.Framing) + { + case PacketFraming.FourByteLittleEndianLength: + case PacketFraming.TwoByteBigEndianLength: + Framing = decodedPacket.Framing; + LogPacket("Recv", decodedPacket.CmdId, decodedPacket.Body.ToArray(),Framing); + await HandlePacket(decodedPacket.CmdId, decodedPacket.Body.ToArray()); + break; + + case PacketFraming.Control: + Logger.Info("Control packet received"); + // Handle control packet if needed + break; + + case PacketFraming.Unknown: + Logger.Warn("Unknown packet format received"); + break; + } + } + } + catch (OperationCanceledException) + { + Logger.Info("ReceiveLoop cancelled"); + } + catch (Exception ex) + { + Logger.Info($"ReceiveLoop error: {ex}"); + } + finally + { + Socket.Close(); + } + Stop(); + } + + private async Task HandlePacket(ushort opcode, byte[] payload) + { + var packetName = LogMap.GetValueOrDefault(opcode); + if (DummyPacketNames.Contains(packetName!)) + { + await SendDummy(packetName!); + Logger.Info($"[Dummy] Send Dummy {packetName}"); + return; + } + + // Find the Handler for this opcode + var handler = HandlerManager.GetHandler(opcode); + if (handler != null) + { + // Handle + // Make sure session is ready for packets + var state = State; + try + { + await handler.OnHandle(this, payload, (ushort)DownStreamSeqNo); + } + catch (Exception e) + { + Logger.Error(e.Message, e); + } + return; + } + + if (ConfigManager.Config.ServerOption.EnableDebug && + ConfigManager.Config.ServerOption.DebugNoHandlerPacket && !IgnoreLog.Contains(opcode)) + Logger.Error($"No handler found for {packetName}({opcode})"); + + //if (ConfigManager.Config.ServerOption.AutoSendResponseWhenNoHandler) + //{ + // await SendDummy(packetName); + //} + + } + + private async Task SendDummy(string packetName) + { + var respName = packetName.Replace("Req", "Rsp"); // Get the response packet name + if (respName == packetName) return; // do not send rsp when resp name = recv name + var respOpcode = LogMap.FirstOrDefault(x => x.Value == respName).Key; // Get the response opcode + + // Send Rsp + await SendPacket(respOpcode); + } +} \ No newline at end of file diff --git a/GameServer/Server/Listener.cs b/GameServer/Server/Listener.cs new file mode 100644 index 0000000..59f00aa --- /dev/null +++ b/GameServer/Server/Listener.cs @@ -0,0 +1,13 @@ +using MikuSB.TcpSharp; + +namespace MikuSB.GameServer.Server; + +public class Listener : SocketListener +{ + public static Connection? GetActiveConnection(int uid) + { + var con = Connections.Values.FirstOrDefault(c => + (c as Connection)?.Player?.Uid == uid && c.State == SessionStateEnum.ACTIVE) as Connection; + return con; + } +} \ No newline at end of file diff --git a/GameServer/Server/Packet/Handler.cs b/GameServer/Server/Packet/Handler.cs new file mode 100644 index 0000000..0f8dc58 --- /dev/null +++ b/GameServer/Server/Packet/Handler.cs @@ -0,0 +1,6 @@ +namespace MikuSB.GameServer.Server.Packet; + +public abstract class Handler +{ + public abstract Task OnHandle(Connection connection, byte[] data, ushort SeqNo = 0); +} \ No newline at end of file diff --git a/GameServer/Server/Packet/HandlerManager.cs b/GameServer/Server/Packet/HandlerManager.cs new file mode 100644 index 0000000..f76600b --- /dev/null +++ b/GameServer/Server/Packet/HandlerManager.cs @@ -0,0 +1,31 @@ +using System.Reflection; + +namespace MikuSB.GameServer.Server.Packet; + +public static class HandlerManager +{ + public static Dictionary handlers = []; + + public static void Init() + { + var classes = Assembly.GetExecutingAssembly().GetTypes(); // Get all classes in the assembly + foreach (var cls in classes) + { + var attribute = (Opcode?)Attribute.GetCustomAttribute(cls, typeof(Opcode)); + + if (attribute != null) handlers.Add(attribute.CmdId, (Handler)Activator.CreateInstance(cls)!); + } + } + + public static Handler? GetHandler(int cmdId) + { + try + { + return handlers[cmdId]; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/GameServer/Server/Packet/Opcode.cs b/GameServer/Server/Packet/Opcode.cs new file mode 100644 index 0000000..00b91eb --- /dev/null +++ b/GameServer/Server/Packet/Opcode.cs @@ -0,0 +1,7 @@ +namespace MikuSB.GameServer.Server.Packet; + +[AttributeUsage(AttributeTargets.Class)] +public class Opcode(int cmdId) : Attribute +{ + public int CmdId = cmdId; +} \ No newline at end of file diff --git a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs new file mode 100644 index 0000000..723d75e --- /dev/null +++ b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs @@ -0,0 +1,50 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Account; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.GameServer.Server.Packet.Send.Login; +using MikuSB.Proto; +using MikuSB.TcpSharp; +using MikuSB.Util; + +namespace MikuSB.GameServer.Server.Packet.Recv.Login; + +[Opcode(CmdIds.ReqLogin)] +public class HandlerReqLogin : Handler +{ + public override async Task OnHandle(Connection connection, byte[] data, ushort seqNo) + { + var req = ReqLogin.Parser.ParseFrom(data); + var account = AccountData.GetAccountByUid(1); + if (account == null) + { + AccountData.CreateAccount("MIKU", 0, ""); + account = AccountData.GetAccountByUid(1); + if (account == null) + { + await connection.SendPacket(CmdIds.NtfLogout); + return; + } + } + if (!ResourceManager.IsLoaded) + // resource manager not loaded, return + return; + var prev = Listener.GetActiveConnection(account.Uid); + if (prev != null) + { + await connection.SendPacket(CmdIds.NtfLogout); + prev.Stop(); + } + + connection.State = SessionStateEnum.WAITING_FOR_LOGIN; + var pd = DatabaseHelper.GetInstance(account.Uid); + connection.Player = pd == null ? new PlayerInstance(account.Uid) : new PlayerInstance(pd); + + connection.DebugFile = Path.Combine(ConfigManager.Config.Path.LogPath, "Debug/", $"{account.Uid}/", + $"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!)); + } +} diff --git a/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs new file mode 100644 index 0000000..4524bba --- /dev/null +++ b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs @@ -0,0 +1,29 @@ +using MikuSB.GameServer.Game.Player; +using MikuSB.TcpSharp; +using MikuSB.Proto; +using MikuSB.Util.Extensions; + +namespace MikuSB.GameServer.Server.Packet.Send.Login; + +public class PacketRspLogin : BasePacket +{ + public PacketRspLogin(PlayerInstance player) : base(CmdIds.RspLogin) + { + var proto = new RspLogin + { + Timestamp = (uint)Extensions.GetUnixSec(), + WorldChannel = 1, + AreaId = 1, + Data = new Player + { + Pid = (ulong)player.Data.Uid, + Account = player.Data.Name, + Name = player.Data.Name, + Level = 80 + }, + NeedRename = false + }; + + SetData(proto); + } +} diff --git a/MikuSB.sln b/MikuSB.sln new file mode 100644 index 0000000..43ce1f1 --- /dev/null +++ b/MikuSB.sln @@ -0,0 +1,60 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34616.47 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SdkServer", "SdkServer\SdkServer.csproj", "{A84C0D8D-BF2E-449A-A46C-F5BE6FD2F42F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{0690883A-D749-42F3-88CB-41D2F627C862}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameServer", "GameServer\GameServer.csproj", "{8E3A0EA5-F4BC-4478-AEB9-CAAC07F10BD3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0C822679-4BCC-497A-AF15-F441EC750CCE}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MikuSB", "MikuSB\MikuSB.csproj", "{71D8488F-CAED-48EE-BD5C-F325FBAB991F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Proto", "Proto\Proto.csproj", "{8A0ECA1A-167B-4B97-BF79-3665AF654A52}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TcpSharp", "TcpSharp\TcpSharp.csproj", "{CD7EFAA3-C655-40EE-8F6A-A8E2DA3B0FCB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A84C0D8D-BF2E-449A-A46C-F5BE6FD2F42F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A84C0D8D-BF2E-449A-A46C-F5BE6FD2F42F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A84C0D8D-BF2E-449A-A46C-F5BE6FD2F42F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A84C0D8D-BF2E-449A-A46C-F5BE6FD2F42F}.Release|Any CPU.Build.0 = Release|Any CPU + {0690883A-D749-42F3-88CB-41D2F627C862}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0690883A-D749-42F3-88CB-41D2F627C862}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0690883A-D749-42F3-88CB-41D2F627C862}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0690883A-D749-42F3-88CB-41D2F627C862}.Release|Any CPU.Build.0 = Release|Any CPU + {8E3A0EA5-F4BC-4478-AEB9-CAAC07F10BD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E3A0EA5-F4BC-4478-AEB9-CAAC07F10BD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E3A0EA5-F4BC-4478-AEB9-CAAC07F10BD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E3A0EA5-F4BC-4478-AEB9-CAAC07F10BD3}.Release|Any CPU.Build.0 = Release|Any CPU + {71D8488F-CAED-48EE-BD5C-F325FBAB991F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71D8488F-CAED-48EE-BD5C-F325FBAB991F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71D8488F-CAED-48EE-BD5C-F325FBAB991F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71D8488F-CAED-48EE-BD5C-F325FBAB991F}.Release|Any CPU.Build.0 = Release|Any CPU + {8A0ECA1A-167B-4B97-BF79-3665AF654A52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A0ECA1A-167B-4B97-BF79-3665AF654A52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A0ECA1A-167B-4B97-BF79-3665AF654A52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A0ECA1A-167B-4B97-BF79-3665AF654A52}.Release|Any CPU.Build.0 = Release|Any CPU + {CD7EFAA3-C655-40EE-8F6A-A8E2DA3B0FCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD7EFAA3-C655-40EE-8F6A-A8E2DA3B0FCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD7EFAA3-C655-40EE-8F6A-A8E2DA3B0FCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD7EFAA3-C655-40EE-8F6A-A8E2DA3B0FCB}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {05A94C2B-B569-45D2-AB39-3F26D02E421A} + EndGlobalSection +EndGlobal diff --git a/MikuSB/MikuSB.csproj b/MikuSB/MikuSB.csproj new file mode 100644 index 0000000..c438f00 --- /dev/null +++ b/MikuSB/MikuSB.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + false + MikuSB.MikuSB + MikuSB + Source\Kiana.ico + false + + + + + + + + + diff --git a/MikuSB/Program/LoaderManager.cs b/MikuSB/Program/LoaderManager.cs new file mode 100644 index 0000000..f0ac644 --- /dev/null +++ b/MikuSB/Program/LoaderManager.cs @@ -0,0 +1,178 @@ +using Microsoft.AspNetCore.Components; +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.GameServer.Command; +using MikuSB.GameServer.Server; +using MikuSB.GameServer.Server.Packet; +using MikuSB.Internationalization; +using MikuSB.MikuSB.Tool; +using MikuSB.Proto; +using MikuSB.SdkServer; +using MikuSB.TcpSharp; +using MikuSB.Util; +using MikuSB.Util.Security; +using System.Reflection; + +namespace MikuSB.MikuSB.Program; + +public class LoaderManager : MikuSB +{ + public static void InitConfig() + { + // Initialize log + var counter = 0; + FileInfo file; + while (true) + { + file = new FileInfo(ConfigManager.Config.Path.LogPath + $"/{DateTime.Now:yyyy-MM-dd}-{++counter}.log"); + if (file is not { Exists: false, Directory: not null }) continue; + file.Directory.Create(); + break; + } + Logger.SetLogFile(file); + + // Init all directories + try + { + ConfigManager.InitDirectories(); + } + catch (Exception e) + { + Logger.Error(I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.Config")), e); + Console.ReadLine(); + return; + } + + // Starting the server + Logger.Info(I18NManager.Translate("Server.ServerInfo.StartingServer")); + + // Load the config + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", I18NManager.Translate("Word.Config"))); + try + { + ConfigManager.LoadConfig(); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.Config")), e); + Console.ReadLine(); + return; + } + + // Load the language + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", I18NManager.Translate("Word.Language"))); + try + { + I18NManager.LoadLanguage(); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.Language")), e); + Console.ReadLine(); + return; + } + } + + public static void InitDatabase() + { + // Initialize the database + try + { + _ = Task.Run(DatabaseHelper.Initialize); // do not wait + + while (!DatabaseHelper.LoadAccount) Thread.Sleep(100); + + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItem", + I18NManager.Translate("Word.DatabaseAccount"))); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.Database")), e); + Console.ReadLine(); + return; + } + } + + public static async Task InitSdkServer() + { + SdkServer.SdkServer.Start([]); + Logger.Info(I18NManager.Translate("Server.ServerInfo.ServerRunning", I18NManager.Translate("Word.Dispatch"), + ConfigManager.Config.HttpServer.GetDisplayAddress())); + + //KcpListener.BaseConnection = typeof(Connection); + //KcpListener.StartListener(); + SocketListener.BaseConnection = typeof(Connection); + SocketListener.StartListener(); + + await Task.CompletedTask; + } + + public static void InitPacket() + { + // get opcode from CmdIds + var opcodes = typeof(CmdIds).GetFields().Where(x => x.FieldType == typeof(int)).ToList(); + foreach (var opcode in opcodes) + { + var name = opcode.Name; + var value = (int)opcode.GetValue(null)!; + SocketConnection.LogMap.TryAdd(value, name); + } + + HandlerManager.Init(); + } + + public static async Task InitResource() + { + // Init custom files + Logger.Info(I18NManager.Translate("Server.ServerInfo.GeneratingItem", I18NManager.Translate("Word.CustomData"))); + try + { + await AssemblyGenerater.LoadCustomData(Assembly.GetExecutingAssembly()); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.CustomData")), e); + Console.ReadLine(); + return; + } + + // Load the game data + try + { + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadingItem", I18NManager.Translate("Word.GameData"))); + ResourceManager.LoadGameData(); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToLoadItem", I18NManager.Translate("Word.GameData")), e); + Console.ReadLine(); + return; + } + } + + public static void InitCommand() + { + // Register the command handlers + try + { + CommandManager.RegisterCommands(); + } + catch (Exception e) + { + Logger.Error( + I18NManager.Translate("Server.ServerInfo.FailedToInitializeItem", + I18NManager.Translate("Word.Command")), e); + Console.ReadLine(); + return; + } + IConsole.OnConsoleExcuteCommand += CommandExecutor.ConsoleExcuteCommand; + CommandExecutor.OnRunCommand += (sender, e) => { CommandManager.HandleCommand(e, sender); }; + + IConsole.ListenConsole(); + } +} \ No newline at end of file diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs new file mode 100644 index 0000000..dd39b04 --- /dev/null +++ b/MikuSB/Program/MikuSB.cs @@ -0,0 +1,90 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.MikuSB.Tool; +using MikuSB.GameServer.Command; +using MikuSB.GameServer.Server; +using MikuSB.Internationalization; +using MikuSB.TcpSharp; +using MikuSB.Util; +using System.Globalization; + +namespace MikuSB.MikuSB.Program; + +public class MikuSB +{ + public static readonly Logger Logger = new("MikuSB"); + public static readonly DatabaseHelper DatabaseHelper = new(); + public static readonly Listener Listener = new(); + public static readonly CommandManager CommandManager = new(); + + public static async Task Main() + { + var time = DateTime.Now; + RegisterExitEvent(); + IConsole.InitConsole(); + LoaderManager.InitConfig(); + await LoaderManager.InitSdkServer(); + LoaderManager.InitPacket(); + + LoaderManager.InitDatabase(); + if (!DatabaseHelper.LoadAllData) + { + var t = Task.Run(() => + { + while (!DatabaseHelper.LoadAllData) // wait for all data to be loaded + Thread.Sleep(100); + }); + + await t.WaitAsync(new CancellationToken()); + + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItem", I18NManager.Translate("Word.Database"))); + } + + Logger.Warn(I18NManager.Translate("Server.ServerInfo.WaitForAllDone")); + + await LoaderManager.InitResource(); + ResourceManager.IsLoaded = true; + + HandbookGenerator.GenerateAll(); + LoaderManager.InitCommand(); + + var elapsed = DateTime.Now - time; + Logger.Info(I18NManager.Translate("Server.ServerInfo.ServerStarted", + Math.Round(elapsed.TotalSeconds, 2).ToString(CultureInfo.InvariantCulture))); + } + + # region Exit + + private static void RegisterExitEvent() + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + Logger.Info(I18NManager.Translate("Server.ServerInfo.Shutdown")); + ProcessExit(); + }; + AppDomain.CurrentDomain.UnhandledException += (obj, arg) => + { + Logger.Error(I18NManager.Translate("Server.ServerInfo.UnhandledException", obj.GetType().Name), + (Exception)arg.ExceptionObject); + Logger.Info(I18NManager.Translate("Server.ServerInfo.Shutdown")); + ProcessExit(); + Environment.Exit(1); + }; + + Console.CancelKeyPress += (_, eventArgs) => + { + Logger.Info(I18NManager.Translate("Server.ServerInfo.CancelKeyPressed")); + eventArgs.Cancel = true; + Environment.Exit(0); + }; + } + + private static void ProcessExit() + { + SocketListener.Connections.Values.ToList().ForEach(x => x.Stop(true)); + DatabaseHelper.SaveThread?.Interrupt(); + DatabaseHelper.SaveDatabase(); + } + + # endregion +} \ No newline at end of file diff --git a/MikuSB/Properties/PublishProfiles/MikuSB-Win64-Debug.pubxml b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-Debug.pubxml new file mode 100644 index 0000000..b46a1e0 --- /dev/null +++ b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-Debug.pubxml @@ -0,0 +1,18 @@ + + + + + Debug + Any CPU + bin\MikuSB-Win64-Debug + FileSystem + <_TargetId>Folder + net9.0 + win-x64 + false + false + false + + \ No newline at end of file diff --git a/MikuSB/Properties/PublishProfiles/MikuSB-Win64-MultiFile.pubxml b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-MultiFile.pubxml new file mode 100644 index 0000000..3ca89f3 --- /dev/null +++ b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-MultiFile.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + bin\MikuSB-MultiFile\ + FileSystem + <_TargetId>Folder + net9.0 + win-x64 + true + false + true + false + + \ No newline at end of file diff --git a/MikuSB/Properties/PublishProfiles/MikuSB-Win64-OneFile.pubxml b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-OneFile.pubxml new file mode 100644 index 0000000..9ac6ab9 --- /dev/null +++ b/MikuSB/Properties/PublishProfiles/MikuSB-Win64-OneFile.pubxml @@ -0,0 +1,19 @@ + + + + + Release + Any CPU + bin\MikuSB-OneFile\ + FileSystem + <_TargetId>Folder + net9.0 + win-x64 + true + true + true + false + + \ No newline at end of file diff --git a/MikuSB/Source/Kiana.ico b/MikuSB/Source/Kiana.ico new file mode 100644 index 0000000..c518cc2 Binary files /dev/null and b/MikuSB/Source/Kiana.ico differ diff --git a/MikuSB/Tool/AssemblyGenerater.cs b/MikuSB/Tool/AssemblyGenerater.cs new file mode 100644 index 0000000..fbc93aa --- /dev/null +++ b/MikuSB/Tool/AssemblyGenerater.cs @@ -0,0 +1,38 @@ +using MikuSB.Util; +using System.Reflection; + +namespace MikuSB.MikuSB.Tool; + +public class AssemblyGenerater +{ + private static readonly string SourceSpace = "MikuSB.MikuSB.Source."; + + public static async ValueTask LoadCustomData(Assembly assembly) + { + string[] embededRes = assembly.GetManifestResourceNames(); + foreach (var res in embededRes) + { + var stream = assembly.GetManifestResourceStream(res); + if (stream != null && res.Contains(ConfigManager.Config.Path.DataPath.Split("/").Last())) + await WriteOutputFiles(stream, res); + } + } + + private async static ValueTask WriteOutputFiles(Stream stream, string resSpace) + { + if (stream == null) return; + + string relativePath = resSpace.Replace(SourceSpace, ""); + int lastDotIndex = relativePath.LastIndexOf('.'); + string outputPath = string.Concat( + ConfigManager.Config.Path.ConfigPath, "/", + relativePath[..lastDotIndex].Replace('.', '/'), + relativePath.AsSpan(lastDotIndex)); + + if (File.Exists(outputPath)) return; // Check if file exist + + using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + stream.Position = 0; + await stream.CopyToAsync(fileStream); + } +} \ No newline at end of file diff --git a/MikuSB/Tool/HandbookGenerator.cs b/MikuSB/Tool/HandbookGenerator.cs new file mode 100644 index 0000000..bd1b147 --- /dev/null +++ b/MikuSB/Tool/HandbookGenerator.cs @@ -0,0 +1,102 @@ +using MikuSB.Data; +using MikuSB.GameServer.Command; +using MikuSB.Internationalization; +using MikuSB.Util; +using Newtonsoft.Json; +using System.Text; +using System.Text.Json.Serialization; + +namespace MikuSB.MikuSB.Tool; + +public static class HandbookGenerator +{ + public static void GenerateAll() + { + var directory = new DirectoryInfo(ConfigManager.Config.Path.ResourcePath + "/TextMap"); + var handbook = new DirectoryInfo(ConfigManager.Config.Path.HandbookPath); + if (!handbook.Exists) handbook.Create(); + if (!directory.Exists) return; + + foreach (var langFile in directory.GetFiles()) + { + if (langFile.Extension != ".json") continue; + var lang = langFile.Name.Replace("TextMap", "").Replace(".json", ""); + + // Check if handbook needs to regenerate + var handbookPath = $"{ConfigManager.Config.Path.HandbookPath}/Handbook{lang}.txt"; + if (File.Exists(handbookPath)) + { + var handbookInfo = new FileInfo(handbookPath); + if (handbookInfo.LastWriteTime >= langFile.LastWriteTime) + continue; // Skip if handbook is newer than language file + } + + Generate(lang); + } + + Logger.GetByClassName() + .Info(I18NManager.Translate("Server.ServerInfo.GeneratedItem", I18NManager.Translate("Word.Handbook"))); + } + + public static void Generate(string lang) + { + var textMapPath = ConfigManager.Config.Path.ResourcePath + "/TextMap/TextMap" + lang + ".json"; + + if (!File.Exists(textMapPath)) + { + Logger.GetByClassName().Error(I18NManager.Translate("Server.ServerInfo.FailedToReadItem", textMapPath, + I18NManager.Translate("Word.NotFound"))); + return; + } + + List textMapList = JsonConvert.DeserializeObject>(File.ReadAllText(textMapPath))!; + + if (textMapList == null) + { + Logger.GetByClassName().Error(I18NManager.Translate("Server.ServerInfo.FailedToReadItem", textMapPath, + I18NManager.Translate("Word.Error"))); + return; + } + + Dictionary textMap = []; + + foreach (var map in textMapList) textMap.Add(map.Value!.Hash, map.Text!); + + var builder = new StringBuilder(); + builder.AppendLine("#Handbook generated in " + DateTime.Now.ToString("yyyy/MM/dd HH:mm")); + builder.AppendLine(); + builder.AppendLine("#Command"); + builder.AppendLine(); + GenerateCmd(builder, lang); + + builder.AppendLine(); + WriteToFile(lang, builder.ToString()); + } + + public static void GenerateCmd(StringBuilder builder, string lang) + { + foreach (var cmd in CommandManager.CommandInfo) + { + builder.Append("\t" + cmd.Key); + var desc = I18NManager.TranslateAsCertainLang(lang, cmd.Value.Description).Replace("\n", "\n\t\t"); + builder.AppendLine(": " + desc); + } + } + + public static void WriteToFile(string lang, string content) + { + File.WriteAllText($"{ConfigManager.Config.Path.HandbookPath}/Handbook{lang}.txt", content); + } + +} + +public class TextMapEntry +{ + [JsonPropertyName("value")] public ValueEntry? Value { get; set; } + [JsonPropertyName("text")] public string? Text { get; set; } +} + +public class ValueEntry +{ + [JsonPropertyName("hash")] public int Hash { get; set; } +} \ No newline at end of file diff --git a/Proto/CmdIds.cs b/Proto/CmdIds.cs new file mode 100644 index 0000000..c613a23 --- /dev/null +++ b/Proto/CmdIds.cs @@ -0,0 +1,136 @@ +namespace MikuSB.Proto; + +public class CmdIds +{ + public const int None = 0; + public const int ReqLogin = 1; + public const int RspLogin = 2; + public const int ReqReconnect = 3; + public const int RspReconnect = 4; + public const int ReqRename = 5; + public const int RspRename = 6; + public const int ReqCallGs = 7; + public const int RspCallGs = 8; + public const int ReqUseItem = 9; + public const int RspUseItem = 10; + public const int ReqReadMail = 11; + public const int RspReadMail = 12; + public const int ReqMailAttachment = 13; + public const int RspMailAttachment = 14; + public const int ReqDelMail = 15; + public const int RspDelMail = 16; + public const int ReqSetNewGuide = 17; + public const int RspSetNewGuide = 18; + public const int ReqAccountInfo = 19; + public const int RspAccountInfo = 20; + public const int ReqResign = 23; + public const int RspResign = 24; + public const int ReqRecord = 25; + public const int RspRecord = 26; + public const int ReqAddFriendReq = 27; + public const int RspAddFriendReq = 28; + public const int ReqAgreeFriendReq = 29; + public const int RspAgreeFriendReq = 30; + public const int ReqRefuseFriendReq = 31; + public const int RspRefuseFriendReq = 32; + public const int ReqRemoveFriend = 33; + public const int RspRemoveFriend = 34; + public const int ReqGiveFriendVigor = 35; + public const int RspGiveFriendVigor = 36; + public const int ReqRecvFriendVigor = 37; + public const int RspRecvFriendVigor = 38; + public const int ReqPlayerRecommend = 39; + public const int RspPlayerRecommend = 40; + public const int ReqAddBlockList = 41; + public const int RspAddBlockList = 42; + public const int ReqDelBlockList = 43; + public const int RspDelBlockList = 44; + public const int ReqFindPlayer = 45; + public const int RspFindPlayer = 46; + public const int ReqPlayerProfile = 47; + public const int RspPlayerProfile = 48; + public const int ReqGetVersion = 49; + public const int RspGetVersion = 50; + public const int ReqRankList = 51; + public const int RspRankList = 52; + public const int ReqRank = 53; + public const int RspRank = 54; + public const int ReqBlockFriendReq = 55; + public const int RspBlockFriendReq = 56; + public const int ReqWordFilter = 57; + public const int RspWordFilter = 58; + public const int ReqSetCustomRoster = 59; + public const int RspSetCustomRoster = 60; + public const int ReqGlobalCounter = 61; + public const int RspGlobalCounter = 62; + public const int ReqMatch = 301; + public const int RspMatch = 302; + public const int ReqOnlineRoom = 303; + public const int RspOnlineRoom = 304; + public const int ReqOnlineRoomStart = 305; + public const int RspOnlineRoomStart = 306; + public const int ReqOnlineRoomExit = 307; + public const int RspOnlineRoomExit = 308; + public const int ReqOnlineRoomInvite = 309; + public const int RspOnlineRoomInvite = 310; + public const int ReqOnlineRoomAccept = 311; + public const int RspOnlineRoomAccept = 312; + public const int ReqOnlineRoomUpdate = 313; + public const int RspOnlineRoomUpdate = 314; + public const int ReqOnlineRoomReconnect = 315; + public const int RspOnlineRoomReconnect = 316; + public const int ReqOnlineRoomChatAccept = 317; + public const int RspOnlineRoomChatAccept = 318; + public const int ReqOnlineRoomUpdateMap = 319; + public const int RspOnlineRoomUpdateMap = 320; + public const int ReqChangeWorldChannel = 321; + public const int RspChangeWorldChannel = 322; + public const int ReqWorldChat = 323; + public const int RspWorldChat = 324; + public const int ReqFriendChat = 325; + public const int RspFriendChat = 326; + public const int ReqOnlineChat = 327; + public const int RspOnlineChat = 328; + public const int ReqOnlineRecruit = 329; + public const int RspOnlineRecruit = 330; + public const int NtfLog = 1001; + public const int NtfKickout = 1002; + public const int NtfBroadcast = 1003; + public const int NtfSyncAttr = 1004; + public const int NtfSyncLineup = 1005; + public const int NtfSyncNewMail = 1006; + public const int NtfSyncDelMail = 1007; + public const int NtfPlayerMsg = 1008; + public const int NtfLogout = 1009; + public const int NtfScript = 1010; + public const int NtfSetAttr = 1011; + public const int NtfSetStrAttr = 1012; + public const int NtfOnlineStart = 1013; + public const int NtfOnlineOver = 1014; + public const int NtfReadItem = 1015; + public const int NtfUpdateFriend = 1016; + public const int NtfDelFriend = 1017; + public const int NtfFriendReq = 1018; + public const int NtfFriendVigor = 1019; + public const int NtfBlacklist = 1020; + public const int NtfGlobalAttrs = 1021; + public const int NtfAntiData = 1022; + public const int NtfBlockFriendReq = 1023; + public const int NtfCustomRoster = 1024; + public const int NtfOnlineRoomInfo = 1031; + public const int NtfOnlineLoad = 1032; + public const int NtfOnlineKickout = 1033; + public const int NtfOnlineInvite = 1034; + public const int NtfOnlineState = 1035; + public const int NtfWorldChat = 1041; + public const int NtfFriendChat = 1042; + public const int NtfOnlineChat = 1043; + public const int NtfOnlineRecruit = 1044; + public const int NtfOnlinePlayerCheat = 1045; + public const int ReqRoomStart = 2001; + public const int RspRoomStart = 2002; + public const int NtfRoomReady = 2003; + public const int NtfRoomOver = 2004; + public const int NtfStopRoom = 2005; + public const int NtfRoomPlayerExit = 2006; +} diff --git a/Proto/Core.proto b/Proto/Core.proto new file mode 100644 index 0000000..1b3044f --- /dev/null +++ b/Proto/Core.proto @@ -0,0 +1,302 @@ +syntax = "proto3"; + +package core; + +option csharp_namespace = "MikuSB.Proto"; + +enum Sex { + MALE = 0; + FEMALE = 1; +} + +enum PlayerCoreAttribute { + LEVEL = 0; + EXP = 1; + VIGOR = 2; + CHARGED = 3; + VIGOR_TIME = 4; +} + +enum MailStat { + New = 0; + Readed = 1; + Geted = 2; + Removed = 3; +} + +enum GlobalMailStat { + Default = 0; + Banned = 1; + Deleted = 2; +} + +enum ChatType { + SYSTEM = 0; + WORLD = 1; + FRIEND = 2; + ONLINE = 3; +} + +message Empty { +} + +message SimpleBoolean { + bool data = 1; +} + +message SimpleUint { + uint64 data = 1; +} + +message SimpleString { + string data = 1; +} + +message IDArray { + repeated uint64 ids = 1; +} + +message StringArray { + repeated string list = 1; +} + +message PlayerProfileArray { + repeated PlayerProfile list = 1; +} + +message ChannelOpt { + string channel = 1; + repeated string subchannels = 2; +} + +message SimpleItem { + uint32 G = 1; + uint32 D = 2; + uint32 P = 3; + uint32 L = 4; + uint32 Count = 5; +} + +message Enhance { + uint32 level = 1; + uint32 exp = 2; + uint32 break = 3; + uint32 evolue = 4; + uint32 trust = 5; + uint32 pro_level = 6; + repeated uint64 spines = 11; + repeated uint32 affixs = 12; +} + +message Item { + uint64 id = 1; + uint64 template = 2; + uint32 count = 3; + uint32 flag = 4; + uint32 userdata = 5; + uint32 expiration = 6; + Enhance enhance = 7; + map slots = 8; +} + +message Lineup { + uint32 index = 1; + string name = 2; + uint64 member1 = 3; + uint64 member2 = 4; + uint64 member3 = 5; +} + +message Player { + uint64 pid = 1; + string account = 2; + string provider = 3; + string channel = 4; + string subchannel = 5; + string name = 11; + string sign = 12; + Sex sex = 13; + uint32 level = 14; + uint32 exp = 15; + uint32 vigor = 16; + map money = 17; + uint32 charged = 18; + uint32 create_time = 31; + uint32 last_login_time = 32; + uint32 last_vigor_time = 33; + uint64 item_id_alloc = 41; + repeated Item items = 42; + repeated Lineup solutions = 43; + map attrs = 44; + map str_attrs = 45; + repeated uint64 show_items = 46; + repeated uint32 show_attrs = 47; + map friend_pieces = 48; + uint64 last_pieces = 49; + map mail_box = 50; + uint32 last_global_mail_time = 51; + uint64 last_person_mid = 52; + repeated uint64 badges = 53; + map order_box = 60; + repeated uint64 tags = 96; + uint64 serial = 97; + uint32 ban_type = 98; + uint32 ban_expr = 99; +} + +message FriendPieces { + uint64 index = 1; + uint64 pid = 2; + uint32 shape = 3; + uint32 expr = 4; + bool deleted = 5; +} + +message PlayerMail { + uint64 mid = 1; + MailStat stat = 2; + uint32 time = 3; + uint32 expiration = 4; +} + +message Mail { + uint64 mid = 1; + string title = 2; + string message = 3; + repeated SimpleItem attachments = 4; + string sender = 5; + uint64 pid = 6; + uint32 time = 7; + uint32 expiration = 8; + uint32 life = 9; + MailStat stat = 10; + bool is_deleted = 99; +} + +message GlobalMail { + uint64 mid = 1; + string sender = 2; + string title = 3; + string message = 4; + repeated SimpleItem attachments = 5; + uint32 start_time = 6; + uint32 end_time = 7; + uint32 life = 8; + uint32 expiration = 9; + uint32 min_level = 10; + uint32 max_level = 12; + uint32 create_begin = 13; + uint32 create_end = 14; + repeated ChannelOpt channels = 15; + GlobalMailStat stat = 99; +} + +message Order { + string trade_no = 1; + string third_trade_no = 2; + uint64 pid = 3; + string product_id = 4; + uint32 product_quantity = 5; + uint32 total_price = 6; + uint32 paid_price = 7; + string finish_time = 8; + bool status = 9; + bool refund_status = 10; + string refund_time = 11; + string subchannel = 12; + string priceunit = 13; + bool supplement_status = 14; + string extendinfo = 15; +} + +message PlayerOrder { + string trade_no = 1; + string subchannel = 2; + uint32 done_time = 3; + uint32 refund_time = 4; + bool is_unreal = 5; + uint32 supplement = 6; +} + +message PlayerProfile { + uint64 pid = 1; + string account = 2; + uint32 create_time = 3; + string name = 4; + string sign = 5; + Sex sex = 6; + uint32 level = 7; + uint32 logout_time = 8; + uint32 friend_count = 9; + repeated Item show_items = 10; + repeated uint32 show_attrs = 11; + repeated Item badges = 12; + repeated uint64 tags = 13; +} + +message ClientProfile { + uint32 plat_id = 1; + string version = 2; + string os_version = 3; + string os_hardware = 4; + string telecom_oper = 5; + string network = 6; + uint32 screen_width = 7; + uint32 screen_height = 8; + float density = 9; + string cpu_profile = 10; + uint32 ram = 11; + string gl_render = 12; + string gl_version = 13; + string device_id = 14; + string resource_version = 15; + string language = 16; +} + +message OnlinePlayer { + uint64 pid = 1; + string name = 2; + uint64 face = 3; + uint64 faceframe = 4; + uint32 level = 5; + Lineup lineup = 6; + repeated Item items = 7; + bool captain = 8; + uint32 stateflag = 9; + repeated uint32 girllovelevel = 10; + map attrs = 11; +} + +message OnlineEndData { + uint64 pid = 1; + string infodata = 2; + uint32 status = 3; +} + +message AccountInfo { + string account = 1; + uint64 pid = 2; + uint32 new_guide = 3; + repeated uint32 error_info = 4; +} + +message ChatMsg { + ChatType type = 1; + uint32 channel_id = 2; + uint64 sender = 3; + uint64 recver = 4; + uint32 time_stamp = 5; + uint64 emoji = 11; + string text = 12; + PlayerProfile profile = 21; +} + +message CustomRoster { + uint64 pid = 1; + map roster = 2; +} + +message GlobalAttrs { + map attrs = 1; + map str_attrs = 2; +} diff --git a/Proto/Proto.csproj b/Proto/Proto.csproj new file mode 100644 index 0000000..4fe010f --- /dev/null +++ b/Proto/Proto.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + MikuProto + MikuSB.Proto + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Proto/Snowbreak.proto b/Proto/Snowbreak.proto new file mode 100644 index 0000000..c013009 --- /dev/null +++ b/Proto/Snowbreak.proto @@ -0,0 +1,381 @@ +syntax = "proto3"; + +package snowbreak; + +import "core.proto"; + +option csharp_namespace = "MikuSB.Proto"; + +enum PF { + NONE = 0; + REQ_LOGIN = 1; + RSP_LOGIN = 2; + REQ_RECONNECT = 3; + RSP_RECONNECT = 4; + REQ_RENAME = 5; + RSP_RENAME = 6; + REQ_CALLGS = 7; + RSP_CALLGS = 8; + REQ_USEITEM = 9; + RSP_USEITEM = 10; + REQ_READMAIL = 11; + RSP_READMAIL = 12; + REQ_MAIL_ATTACHMENT = 13; + RSP_MAIL_ATTACHMENT = 14; + REQ_DELMAIL = 15; + RSP_DELMAIL = 16; + REQ_SET_NEWGUIDE = 17; + RSP_SET_NEWGUIDE = 18; + REQ_ACCOUNTINFO = 19; + RSP_ACCOUNTINFO = 20; + REQ_RESIGN = 23; + RSP_RESIGN = 24; + REQ_RECORD = 25; + RSP_RECORD = 26; + REQ_ADD_FRIENDREQ = 27; + RSP_ADD_FRIENDREQ = 28; + REQ_AGREE_FRIENDREQ = 29; + RSP_AGREE_FRIENDREQ = 30; + REQ_REFUSE_FRIENDREQ = 31; + RSP_REFUSE_FRIENDREQ = 32; + REQ_REMOVE_FRIEND = 33; + RSP_REMOVE_FRIEND = 34; + REQ_GIVE_FRIENDVIGOR = 35; + RSP_GIVE_FRIENDVIGOR = 36; + REQ_RECV_FRIENDVIGOR = 37; + RSP_RECV_FRIENDVIGOR = 38; + REQ_PLAYER_RECOMMEND = 39; + RSP_PLAYER_RECOMMEND = 40; + REQ_ADD_BLOCKLIST = 41; + RSP_ADD_BLOCKLIST = 42; + REQ_DEL_BLOCKLIST = 43; + RSP_DEL_BLOCKLIST = 44; + REQ_FIND_PLAYER = 45; + RSP_FIND_PLAYER = 46; + REQ_PLAYER_PROFILE = 47; + RSP_PLAYER_PROFILE = 48; + REQ_GET_VERSION = 49; + RSP_GET_VERSION = 50; + REQ_RANKLIST = 51; + RSP_RANKLIST = 52; + REQ_RANK = 53; + RSP_RANK = 54; + REQ_BLOCK_FRIENDREQ = 55; + RSP_BLOCK_FRIENDREQ = 56; + REQ_WORD_FILTER = 57; + RSP_WORD_FILTER = 58; + REQ_SET_CUSTOMROSTER = 59; + RSP_SET_CUSTOMROSTER = 60; + REQ_GLOBALCOUNTER = 61; + RSP_GLOBALCOUNTER = 62; + REQ_MATCH = 301; + RSP_MATCH = 302; + REQ_ONLINE_ROOM = 303; + RSP_ONLINE_ROOM = 304; + REQ_ONLINE_ROOM_START = 305; + RSP_ONLINE_ROOM_START = 306; + REQ_ONLINE_ROOM_EXIT = 307; + RSP_ONLINE_ROOM_EXIT = 308; + REQ_ONLINE_ROOM_INVITE = 309; + RSP_ONLINE_ROOM_INVITE = 310; + REQ_ONLINE_ROOM_ACCEPT = 311; + RSP_ONLINE_ROOM_ACCEPT = 312; + REQ_ONLINE_ROOM_UPDATE = 313; + RSP_ONLINE_ROOM_UPDATE = 314; + REQ_ONLINE_ROOM_RECONNECT = 315; + RSP_ONLINE_ROOM_RECONNECT = 316; + REQ_ONLINE_ROOM_CHATACCEPT = 317; + RSP_ONLINE_ROOM_CHATACCEPT = 318; + REQ_ONLINE_ROOM_UPDATEMAP = 319; + RSP_ONLINE_ROOM_UPDATEMAP = 320; + REQ_CHANGE_WORLD_CHANNEL = 321; + RSP_CHANGE_WORLD_CHANNEL = 322; + REQ_WORLD_CHAT = 323; + RSP_WORLD_CHAT = 324; + REQ_FRIEND_CHAT = 325; + RSP_FRIEND_CHAT = 326; + REQ_ONLINE_CHAT = 327; + RSP_ONLINE_CHAT = 328; + REQ_ONLINE_RECRUIT = 329; + RSP_ONLINE_RECRUIT = 330; + NTF_LOG = 1001; + NTF_KICKOUT = 1002; + NTF_BROADCAST = 1003; + NTF_SYNCATTR = 1004; + NTF_SYNCLINEUP = 1005; + NTF_SYNC_NEW_MAIL = 1006; + NTF_SYNC_DEL_MAIL = 1007; + NTF_PLAYERMSG = 1008; + NTF_LOGOUT = 1009; + NTF_SCRIPT = 1010; + NTF_SETATTR = 1011; + NTF_SETSTRATTR = 1012; + NTF_ONLINE_START = 1013; + NTF_ONLINE_OVER = 1014; + NTF_READITEM = 1015; + NTF_UPDATE_FRIEND = 1016; + NTF_DEL_FRIEND = 1017; + NTF_FRIEND_REQ = 1018; + NTF_FRIEND_VIGOR = 1019; + NTF_BLACKLIST = 1020; + NTF_GLOBALATTRS = 1021; + NTF_ANTI_DATA = 1022; + NTF_BLOCK_FRIENDREQ = 1023; + NTF_CUSTOMROSTER = 1024; + NTF_ONLINE_ROOMINFO = 1031; + NTF_ONLINE_LOAD = 1032; + NTF_ONLINE_KICKOUT = 1033; + NTF_ONLINE_INVITE = 1034; + NTF_ONLINE_STATE = 1035; + NTF_WORLD_CHAT = 1041; + NTF_FRIEND_CHAT = 1042; + NTF_ONLINE_CHAT = 1043; + NTF_ONLINE_RECRUIT = 1044; + NTF_ONLINE_PLAYERCHEAT = 1045; + REQ_ROOM_START = 2001; + RSP_ROOM_START = 2002; + NTF_ROOM_READY = 2003; + NTF_ROOM_OVER = 2004; + NTF_STOP_ROOM = 2005; + NTF_ROOM_PLAYEREXIT = 2006; + NTF_ROOM_PLAYERCHEAT = 2007; + NTF_ROOM_PLAYERFINAL = 2008; +} + +message ReqLogin { + string provider = 1; + string token = 2; + core.ClientProfile client_profile = 3; +} + +message RspLogin { + string session_id = 1; + core.Player data = 2; + bool need_rename = 3; + uint32 area_id = 4; + int32 time_zone = 5; + uint32 timestamp = 6; + int32 certification = 7; + map global_attrs = 8; + uint32 world_channel = 9; + map global_str_attrs = 10; + uint32 error_code = 98; + repeated uint32 error_info = 99; +} + +message ReqReconnect { + uint64 pid = 1; + string session_id = 2; + uint32 world_channel = 3; + string language = 4; +} + +message RspReconnect { + string session_id = 1; + core.Player data = 2; + bool need_rename = 3; + int32 time_zone = 4; + uint32 timestamp = 5; + uint32 world_channel = 6; +} + +message ReqAccountInfo { + string provider = 1; + string token = 2; +} + +message ReqCallGS { + string api = 1; + string param = 2; + uint32 clicknum = 3; + repeated string dependent_params = 4; +} + +message ReqUseItem { + uint64 id = 1; + uint32 count = 2; +} + +message ReqOnlineCreateRoom { + uint32 onlineid = 1; + uint32 lineup_index = 2; +} + +message RspOnlineCreateRoom { + uint32 onlineid = 1; + uint32 lineup_index = 2; + uint64 roomid = 3; + repeated uint32 buffinfo = 4; +} + +message ReqOnlineAccept { + uint64 otherid = 1; + uint32 onlineid = 2; +} + +message ReqOnlineChatAccept { + uint64 otherid = 1; + uint32 onlineid = 2; + uint64 roomid = 3; +} + +message ReqOnlineRecruit { + uint64 room_id = 1; + uint32 online_id = 2; +} + +message FriendVigor { + uint64 pid = 1; + bool have_vigor = 2; + bool vigor_got = 3; + bool return_vigor = 4; +} + +message FriendVigorList { + repeated FriendVigor list = 1; +} + +message RankList { + message ListItem { + string member_name = 1; + uint32 score = 2; + string info = 3; + } + string rank_name = 1; + repeated RankList.ListItem list = 2; +} + +message RankInfo { + uint32 score = 1; + uint32 rank = 2; + uint32 sum = 3; + string info = 4; +} + +message GlobalCounterInfo { + string counter_name = 1; + uint32 value = 2; +} + +message NtfLog { + string action = 1; + string detail = 2; +} + +message NtfBroadcast { + string msg = 1; + uint32 duration = 2; + uint32 start_time = 3; + uint32 end_time = 4; + bool clean = 5; + repeated core.ChannelOpt channels = 6; +} + +message NtfSyncPlayer { + string sign = 1; + map core = 2; + map custom = 3; + map custom_str = 4; + repeated core.Item items = 5; + repeated uint64 show_items = 6; + repeated uint32 show_attrs = 7; + map money = 8; + repeated core.FriendPieces pieces = 9; + repeated uint64 badges = 10; + repeated uint64 tags = 11; +} + +message NtfSyncLineup { + core.Lineup lineup = 1; + bool remove = 2; +} + +message NtfCallScript { + string api = 1; + string arg = 2; + NtfSyncPlayer extra_sync = 3; +} + +message NtfSetAttr { + uint32 gid = 1; + uint32 sid = 2; + uint32 val = 3; +} + +message NtfSetStrAttr { + uint32 gid = 1; + uint32 sid = 2; + string val = 3; +} + +message NtfOnlineStart { + uint64 room_id = 1; + string room_addr = 2; +} + +message NtfOnlineRoom { + uint64 room_id = 1; + uint32 onlineid = 2; + repeated core.OnlinePlayer players = 3; + bool bmatch = 4; + repeated uint32 buffinfo = 5; +} + +message NtfOnlineInvite { + uint64 room_id = 1; + uint32 onlineid = 2; + uint64 playerid = 3; + string name = 4; + uint64 face = 5; + uint64 faceframe = 6; + uint32 level = 7; +} + +message NtfOnlineRecruit { + uint64 room_id = 1; + uint32 online_id = 2; + core.PlayerProfile sender_profile = 3; +} + +message NtfOnlineState { + uint64 room_id = 1; + uint32 onlineid = 2; + uint32 matchflag = 3; + repeated uint64 players = 4; + repeated uint32 stateflag = 5; + uint64 nowtime = 6; + uint64 levelid = 7; +} + +message ReqRoomStart { + uint64 room_id = 1; + bool is_reday = 2; + string error = 3; +} + +message RspRoomStart { + repeated core.OnlinePlayer players = 1; + repeated uint32 buffinfo = 2; + uint32 pollingweek = 3; +} + +message NtfStopRoom { + uint64 room_id = 1; + string reason = 2; +} + +message NtfRoomOver { + uint64 room_id = 1; + repeated core.OnlineEndData playerinfo = 2; +} + +message ReqAntiData { + uint32 data_type = 1; + bytes mtpData = 2; + int32 plat_id = 4; +} + +message NtfRoomPlayerCheat { + uint64 room_id = 1; + uint64 playerid = 2; +} diff --git a/SdkServer/Handlers/RouteController.cs b/SdkServer/Handlers/RouteController.cs new file mode 100644 index 0000000..b8d1f76 --- /dev/null +++ b/SdkServer/Handlers/RouteController.cs @@ -0,0 +1,285 @@ +using Microsoft.AspNetCore.Mvc; +using MikuSB.Configuration; +using MikuSB.SdkServer.Models; +using MikuSB.Util; +using System.Text; +using System.Text.Json; + +namespace MikuSB.SdkServer.Handlers; + +[ApiController] +public class RouteController : ControllerBase +{ + public static ConfigContainer Config = ConfigManager.Config; + + public static object BuildServerList(string version = "") + { + return new + { + code = 0, + ret = 0, + msg = "ok", + message = "ok", + version, + server_time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + servers = new[] + { + new + { + id = 1, + server_id = 1, + name = Config.GameServer.GameServerName, + title = Config.GameServer.GameServerName, + host = Config.GameServer.PublicAddress, + ip = Config.GameServer.PublicAddress, + port = Config.GameServer.Port, + status = 1, + state = 1, + is_open = true, + open = true, + recommend = true + } + }, + game_server = new + { + host = Config.GameServer.PublicAddress, + ip = Config.GameServer.PublicAddress, + port = Config.GameServer.Port + }, + http_server = new + { + host = Config.HttpServer.PublicAddress, + port = Config.HttpServer.Port + } + }; + } + + private static string? ExtractUid(string? authInfo) + { + if (string.IsNullOrWhiteSpace(authInfo)) + return null; + + try + { + var normalized = Uri.UnescapeDataString(authInfo).Trim(); + var padding = normalized.Length % 4; + if (padding > 0) + normalized = normalized.PadRight(normalized.Length + (4 - padding), '='); + + var json = Encoding.UTF8.GetString(Convert.FromBase64String(normalized)); + using var document = JsonDocument.Parse(json); + return document.RootElement.TryGetProperty("uid", out var uid) ? uid.GetString() : null; + } + catch + { + return null; + } + } + + [HttpGet("/getGameConfig")] + [HttpPost("/getGameConfig")] + public IActionResult GetGameConfig() + { + object rsp = new + { + code = "0", + data = new + { + agreementUpdateTime = "1728552600000", + appDownLoadUrl = "", + enableReportDataToDouyin = false, + loginType = new[] { "channel" }, + openActivationCode = false, + qqGroup = (string?)null + }, + msg = "success" + }; + + return Ok(rsp); + } + + [HttpGet("/seasun/config")] + [HttpPost("/seasun/config")] + public IActionResult GetSeasunConfig() + { + object rsp = new + { + code = 0, + data = new + { + agreementUpdateTime = "1728552600000", + appDownLoadUrl = "", + enableReportDataToDouyin = false, + loginType = new[] { "channel" }, + openActivationCode = false, + qqGroup = (string?)null, + privacyUpdateTime = "1728552600000", + realNameAuth = false + }, + msg = "success" + }; + + return Ok(rsp); + } + + [HttpGet("/seasun/loginByToken")] + [HttpPost("/seasun/loginByToken")] + public IActionResult LoginByToken( + [FromQuery] string? uid, + [FromQuery] string? token, + [FromForm] string? form_uid, + [FromForm] string? form_token + ) + { + string finalUid = uid ?? form_uid ?? "10001"; + string finalToken = token ?? form_token ?? Guid.NewGuid().ToString("N"); + + object rsp = new + { + code = 0, + data = new + { + associatedAccounts = new[] + { + new { bindStatus = false, nickname = "", thirdPartyType = "mail" }, + new { bindStatus = true, nickname = Config.GameServer.GameServerName, thirdPartyType = "google" }, + new { bindStatus = false, nickname = "", thirdPartyType = "twitter" }, + new { bindStatus = false, nickname = "", thirdPartyType = "guest" }, + new { bindStatus = false, nickname = "", thirdPartyType = "steam" } + }, + isFirstLogin = false, + isNeedKoreaSciAuth = false, + ksOpenId = $"ks_{finalUid}", + nickname = Config.GameServer.GameServerName, + passportId = finalUid.Length > 10 ? finalUid[^10..] : finalUid, + playerFillAgeUrl = "", + status = 0, + thirdPartyUid = "", + finalToken, + type = "google", + uid = finalUid + }, + msg = "操作成功" + }; + + return Ok(rsp); + } + + [HttpGet("/seasun/getAccountInfoForGame")] + [HttpPost("/seasun/getAccountInfoForGame")] + public IActionResult GetAccountInfoForGame( + [FromQuery] string? uid, + [FromForm] string? form_uid + ) + { + string uidString = uid ?? form_uid ?? "10001"; + var finalUid = int.TryParse(uidString, out int parsedUid) ? parsedUid : 10001; + + object rsp = new + { + code = 0, + data = new + { + bindAccountTypes = new[] { "google" }, + channelUid = uidString, + loginAccountType = "google", + nickName = Config.GameServer.GameServerName, + passportId = uidString.Length > 10 ? uidString[^10..] : uidString, + uid = $"seasun__{uid}" + }, + msg = "操作成功" + }; + + return Ok(rsp); + } + + [HttpPost("/bisdk/batchpush")] + public IActionResult GetBatchPush() + { + object rsp = new + { + code = 0, + ret = 0, + msg = "ok", + message = "ok" + }; + + return Ok(rsp); + } + + [HttpGet("/query")] + public IActionResult GetQuery([FromQuery] string? version, [FromQuery] string? platform) + { + object rsp = new + { + platform, + version, + host = Config.GameServer.PublicAddress, + port = Config.GameServer.Port + }; + + return Ok(rsp); + } + + [HttpGet("/query_version={version}")] + public IActionResult GetQueryVersionV1(string version) + { + return Ok(BuildServerList(version)); + } + + [HttpGet("/query_version")] + public IActionResult GetQueryVersionV2([FromQuery] string version) + { + return Ok(BuildServerList(version)); + } + + [HttpGet("/api/serverlist")] + public IActionResult GetServerList() + { + return Ok(BuildServerList()); + } + + [HttpGet("/account/query-uid/{appId}")] + public IActionResult QueryUid(string appId, [FromQuery] string authInfo) + { + var uid = ExtractUid(authInfo) ?? "10001"; + + object rsp = new + { + code = "0", + msg = "success", + data = new + { + uid = $"seasun__{uid}" + } + }; + + return Ok(rsp); + } + + [HttpGet("/health")] + public IActionResult HealthCheck() + { + object rsp = new + { + status = "ok", + service = Config.GameServer.GameServerName + }; + + return Ok(rsp); + } + + [HttpPost("/api/auth/guest")] + public IActionResult AuthGuest([FromQuery] string? Token) + { + object rsp = new + { + Provider = "Guest", + Token = Token, + Account = "Account", + Pid = "123813131321312" + }; + + return Ok(rsp); + } +} diff --git a/SdkServer/Models/ResponseBase.cs b/SdkServer/Models/ResponseBase.cs new file mode 100644 index 0000000..b0a3dab --- /dev/null +++ b/SdkServer/Models/ResponseBase.cs @@ -0,0 +1,9 @@ +namespace MikuSB.SdkServer.Models; + +public class ResponseBase +{ + public string Msg { get; set; } = "OK"; + public bool Success { get; set; } = true; + public int Code { get; set; } + public object? Data { get; set; } +} diff --git a/SdkServer/SdkServer.cs b/SdkServer/SdkServer.cs new file mode 100644 index 0000000..68c74cc --- /dev/null +++ b/SdkServer/SdkServer.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MikuSB.SdkServer.Handlers; +using MikuSB.SdkServer.Utils; +using MikuSB.Util; +using System.Text.Json; + +namespace MikuSB.SdkServer; + +public static class SdkServer +{ + public static void Start(string[] args) + { + BuildWebHost(args).RunAsync(); + } + + private static IWebHost BuildWebHost(string[] args) + { + var builder = WebHost.CreateDefaultBuilder(args) + .UseStartup() + .ConfigureLogging((_, logging) => { logging.ClearProviders(); }) + .UseUrls(ConfigManager.Config.HttpServer.GetDisplayAddress()); + + return builder.Build(); + } +} + +public class Startup +{ + private static bool LooksLikeServerListRequest(string path, string? query) + { + var value = $"{path}?{query}".ToLowerInvariant(); + return value.Contains("server") + || value.Contains("version") + || value.Contains("query_version") + || value.Contains("serverlist"); + } + + public static void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); + + app.UseRouting(); + app.UseCors("AllowAll"); + app.UseAuthorization(); + app.UseMiddleware(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapFallback(async context => + { + var path = context.Request.Path.Value ?? ""; + if (LooksLikeServerListRequest(path, context.Request.QueryString.Value)) + { + var response = RouteController.BuildServerList(""); + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + return; + } + var fallbackResponse = new + { + code = 0, + message = "ok", + service = ConfigManager.Config.GameServer.GameServerName, + path = path, + query = context.Request.QueryString.Value ?? "" + }; + + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(fallbackResponse)); + }); + }); + } + + public static void ConfigureServices(IServiceCollection services) + { + services.AddCors(options => + { + options.AddPolicy("AllowAll", + builder => { builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); }); + }); + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + }); + services.AddSingleton(_ => new Logger("HttpServer")); + } +} diff --git a/SdkServer/SdkServer.csproj b/SdkServer/SdkServer.csproj new file mode 100644 index 0000000..b69edd1 --- /dev/null +++ b/SdkServer/SdkServer.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + MikuSB.SdkServer + + + + + + + + + + + + diff --git a/SdkServer/Utils/JsonStringToObjectConverter.cs b/SdkServer/Utils/JsonStringToObjectConverter.cs new file mode 100644 index 0000000..bf009d8 --- /dev/null +++ b/SdkServer/Utils/JsonStringToObjectConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.SdkServer.Utils; + +public class JsonStringToObjectConverter : JsonConverter where T : class +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + return JsonSerializer.Deserialize(ref reader, options); + + var jsonString = reader.GetString(); + return !string.IsNullOrEmpty(jsonString) + ? JsonSerializer.Deserialize(jsonString, options) + : null; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var json = JsonSerializer.Serialize(value, options); + writer.WriteStringValue(json); + } +} diff --git a/SdkServer/Utils/LoggingMiddleware.cs b/SdkServer/Utils/LoggingMiddleware.cs new file mode 100644 index 0000000..922b7ee --- /dev/null +++ b/SdkServer/Utils/LoggingMiddleware.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using MikuSB.Util; + +namespace MikuSB.SdkServer.Utils; + +public class RequestLoggingMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, Logger logger) + { + var request = context.Request; + var method = request.Method; + var path = request.Path + request.QueryString; + + await next(context); + + var statusCode = context.Response.StatusCode; + + if (path.StartsWith("/report") || path.Contains("/log/") || path == "/alive") + return; + + if (statusCode == 200) + { + logger.Info($"{method} {path} => {statusCode}"); + } + else if (statusCode == 404) + { + logger.Warn($"{method} {path} => {statusCode}"); + } + else + { + logger.Error($"{method} {path} => {statusCode}"); + } + } +} diff --git a/TcpSharp/BasePacket.cs b/TcpSharp/BasePacket.cs new file mode 100644 index 0000000..f0241e5 --- /dev/null +++ b/TcpSharp/BasePacket.cs @@ -0,0 +1,51 @@ +using Google.Protobuf; +using MikuSB.Enums.Packet; + +namespace MikuSB.TcpSharp; + +public class BasePacket +{ + public ushort CmdId { get; set; } + public byte[] Body { get; set; } + public ushort SeqNo { get; set; } + public ushort PushSeq { get; set; } + public long Timestamp { get; set; } + public IMessage? Message { get; set; } + public PacketFraming Framing { get; set; } + + public BasePacket(ushort cmdId) + { + CmdId = cmdId; + Body = Array.Empty(); + SeqNo = 0; + PushSeq = 0; + Timestamp = 0; + Framing = PacketFraming.FourByteLittleEndianLength; + } + + public BasePacket(ushort cmdId, byte[] body, PacketFraming framing = PacketFraming.FourByteLittleEndianLength) + { + CmdId = cmdId; + Body = body ?? Array.Empty(); + Framing = framing; + SeqNo = 0; + PushSeq = 0; + Timestamp = 0; + } + + public void SetData(byte[] data) + { + Body = data; + } + + public void SetData(IMessage message) + { + Body = message.ToByteArray(); + Message = message; + } + + public void SetData(string base64) + { + SetData(Convert.FromBase64String(base64)); + } +} \ No newline at end of file diff --git a/TcpSharp/PacketCodec.cs b/TcpSharp/PacketCodec.cs new file mode 100644 index 0000000..99c17aa --- /dev/null +++ b/TcpSharp/PacketCodec.cs @@ -0,0 +1,308 @@ +using Google.Protobuf; +using MikuSB.Enums.Packet; +using MikuSB.Util; +using System.Buffers.Binary; +using System.Net.Sockets; + +namespace MikuSB.TcpSharp +{ + public class PacketCodec + { + private const int HeaderSize4Byte = 4; + private const int HeaderSize2Byte = 2; + private const int MaxPacketLength = 1024 * 1024; + private const ushort ClientMagic = 0x011F; + private const int ControlPacketSize = 35; + + private static readonly Logger Logger = new("PacketCodec"); + + public PacketCodec() + { + + } + + public async Task ReadPacketAsync( + Stream stream, + CancellationToken cancellationToken = default) + { + try + { + var lengthBuffer = new byte[HeaderSize4Byte]; + if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken)) + { + Logger.Debug("Connection closed before packet header"); + return null; + } + + var framing = DetectFraming(lengthBuffer); + + switch (framing) + { + case PacketFraming.Control: + return await HandleControlPacket(stream, cancellationToken); + + case PacketFraming.TwoByteBigEndianLength: + return await HandleTwoBytePacket(stream, lengthBuffer, cancellationToken); + + case PacketFraming.FourByteLittleEndianLength: + return await HandleFourBytePacket(stream, lengthBuffer, cancellationToken); + + default: + return await HandleUnknownPacket(stream, lengthBuffer, cancellationToken); + } + } + catch (OperationCanceledException) + { + Logger.Debug("Packet read cancelled"); + return null; + } + catch (Exception ex) + { + Logger.Error($"Error reading packet {ex}"); + return null; + } + } + + public byte[] Encode(ushort packetId, byte[] payload, PacketFraming framing = PacketFraming.FourByteLittleEndianLength) + { + return framing switch + { + PacketFraming.TwoByteBigEndianLength => EncodeTwoByteFrame(packetId, payload), + PacketFraming.FourByteLittleEndianLength => EncodeFourByteFrame(packetId, payload), + _ => EncodeFourByteFrame(packetId, payload) + }; + } + + public byte[] EncodeRaw(ushort packetId, byte[] payload, PacketFraming framing = PacketFraming.FourByteLittleEndianLength) + { + return framing switch + { + PacketFraming.TwoByteBigEndianLength => EncodeTwoByteFrame(packetId, payload), + PacketFraming.FourByteLittleEndianLength => EncodeFourByteFrame(packetId, payload), + _ => EncodeFourByteFrame(packetId, payload) + }; + } + + #region Private Methods + + private PacketFraming DetectFraming(byte[] header) + { + var firstTwoBytes = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(0, 2)); + var nextTwoBytes = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(2, 2)); + + if (firstTwoBytes == ClientMagic && nextTwoBytes == 0) + return PacketFraming.Control; + + if (firstTwoBytes == ClientMagic && IsValidPacketId(nextTwoBytes)) + return PacketFraming.TwoByteBigEndianLength; + + if (IsValidTwoByteHeader(firstTwoBytes, (ushort)nextTwoBytes)) + return PacketFraming.TwoByteBigEndianLength; + + return PacketFraming.FourByteLittleEndianLength; + } + + private async Task HandleControlPacket(Stream stream, CancellationToken cancellationToken) + { + var controlData = new byte[ControlPacketSize]; + if (!await ReadExactAsync(stream, controlData, cancellationToken)) + { + Logger.Debug("Connection closed during control packet read"); + return null; + } + + Logger.Debug("Control packet received"); + return new BasePacket(0) + { + Framing = PacketFraming.Control, + Body = Array.Empty() + }; + } + + private async Task HandleTwoBytePacket( + Stream stream, + byte[] header, + CancellationToken cancellationToken) + { + var packetId = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(2, 2)); + + var wrapper = new byte[ControlPacketSize]; + if (!await ReadExactAsync(stream, wrapper, cancellationToken)) + { + Logger.Debug($"Connection closed during wrapper read for packet {packetId}"); + return null; + } + + var payloadLength = BinaryPrimitives.ReadUInt16LittleEndian(wrapper.AsSpan(6, 2)); + var payload = await ReadPayloadAsync(stream, payloadLength, cancellationToken); + + if (payload == null) + return null; + + //Logger.Debug($"Packet received (2-byte framing): ID={packetId}, PayloadSize={payload.Length}"); + + return new BasePacket(packetId) + { + Framing = PacketFraming.TwoByteBigEndianLength, + Body = payload + }; + } + + private async Task HandleFourBytePacket( + Stream stream, + byte[] header, + CancellationToken cancellationToken) + { + var length = BinaryPrimitives.ReadUInt32LittleEndian(header); + + if (length < 2 || length > MaxPacketLength) + { + Logger.Warn($"Invalid packet length: {length}"); + return null; + } + + var frame = new byte[length]; + if (!await ReadExactAsync(stream, frame, cancellationToken)) + { + Logger.Debug("Connection closed during packet body read"); + return null; + } + + var packetId = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(0, 2)); + var payload = frame[2..]; + + //Logger.Debug($"Packet received (4-byte framing): ID={packetId}, PayloadSize={payload.Length}"); + + return new BasePacket(packetId) + { + Framing = PacketFraming.FourByteLittleEndianLength, + Body = payload + }; + } + + private async Task HandleUnknownPacket( + Stream stream, + byte[] header, + CancellationToken cancellationToken) + { + var extraData = await ReadAvailableBytesAsync(stream, cancellationToken); + var combinedData = new byte[header.Length + extraData.Length]; + header.CopyTo(combinedData, 0); + extraData.CopyTo(combinedData, header.Length); + + Logger.Warn($"Unknown packet format detected, captured {combinedData.Length} bytes"); + + return new BasePacket(0) + { + Framing = PacketFraming.Unknown, + Body = combinedData + }; + } + + private async Task ReadPayloadAsync( + Stream stream, + int length, + CancellationToken cancellationToken) + { + if (length <= 0) + return Array.Empty(); + + if (length > MaxPacketLength) + { + Logger.Warn($"Payload too large: {length}"); + return null; + } + + var payload = new byte[length]; + if (!await ReadExactAsync(stream, payload, cancellationToken)) + return null; + + return payload; + } + + private byte[] EncodeTwoByteFrame(ushort packetId, byte[] payload) + { + var wrappedPayload = WrapPayload(payload); + var buffer = new byte[HeaderSize4Byte + wrappedPayload.Length]; + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, 2), ClientMagic); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), packetId); + wrappedPayload.CopyTo(buffer.AsSpan(HeaderSize4Byte)); + + return buffer; + } + + private byte[] EncodeFourByteFrame(ushort packetId, byte[] payload) + { + var buffer = new byte[HeaderSize4Byte + HeaderSize2Byte + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), (uint)(HeaderSize2Byte + payload.Length)); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(4, 2), packetId); + payload.CopyTo(buffer.AsSpan(HeaderSize4Byte + HeaderSize2Byte)); + return buffer; + } + + private byte[] WrapPayload(byte[] payload) + { + const int wrapperHeaderSize = 35; + var wrapped = new byte[wrapperHeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(wrapped.AsSpan(6, 2), (ushort)payload.Length); + wrapped[11] = 1; + payload.CopyTo(wrapped.AsSpan(wrapperHeaderSize)); + + return wrapped; + } + + private static async Task ReadExactAsync( + Stream stream, + byte[] buffer, + CancellationToken cancellationToken) + { + var offset = 0; + while (offset < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(offset), cancellationToken); + if (read == 0) + return false; + + offset += read; + } + return true; + } + + private static async Task ReadAvailableBytesAsync( + Stream stream, + CancellationToken cancellationToken) + { + if (stream is not NetworkStream networkStream || !networkStream.DataAvailable) + return Array.Empty(); + + using var ms = new MemoryStream(); + var buffer = new byte[4096]; + + while (networkStream.DataAvailable && ms.Length < 16384) + { + var read = await networkStream.ReadAsync(buffer, cancellationToken); + if (read <= 0) + break; + + ms.Write(buffer, 0, read); + } + + return ms.ToArray(); + } + + private static bool IsValidTwoByteHeader(int firstTwoBytes, ushort packetId) + { + return firstTwoBytes >= 2 + && firstTwoBytes <= ushort.MaxValue + && IsValidPacketId(packetId); + } + + private static bool IsValidPacketId(ushort packetId) + { + return packetId != 0; + } + + #endregion + } +} \ No newline at end of file diff --git a/TcpSharp/SessionStateEnum.cs b/TcpSharp/SessionStateEnum.cs new file mode 100644 index 0000000..f89496a --- /dev/null +++ b/TcpSharp/SessionStateEnum.cs @@ -0,0 +1,10 @@ +namespace MikuSB.TcpSharp; + +public enum SessionStateEnum +{ + INACTIVE, + WAITING_FOR_TOKEN, + WAITING_FOR_LOGIN, + PICKING_CHARACTER, + ACTIVE +} \ No newline at end of file diff --git a/TcpSharp/SocketConnection.cs b/TcpSharp/SocketConnection.cs new file mode 100644 index 0000000..97a7ea2 --- /dev/null +++ b/TcpSharp/SocketConnection.cs @@ -0,0 +1,203 @@ +using Google.Protobuf; +using Google.Protobuf.Reflection; +using MikuSB.Enums.Packet; +using MikuSB.Proto; +using MikuSB.Util; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; + +namespace MikuSB.TcpSharp; + +public class SocketConnection +{ + public static readonly ConcurrentBag BannedPackets = []; + private static readonly Logger Logger = new("GameServer"); + public static readonly ConcurrentDictionary LogMap = []; + + public static readonly ConcurrentBag IgnoreLog = + [ + + ]; + protected readonly CancellationTokenSource CancelToken; + protected readonly Socket Socket; + public readonly IPEndPoint RemoteEndPoint; + + public string DebugFile = ""; + public bool IsOnline = true; + public StreamWriter? Writer; + + public int DownStreamSeqNo; + public int UpStreamSeqNo; + + public PacketFraming Framing; + + public SocketConnection(Socket socket, IPEndPoint remote) + { + Socket = socket; + RemoteEndPoint = remote; + CancelToken = new CancellationTokenSource(); + + Start(); + } + public SessionStateEnum State { get; set; } = SessionStateEnum.INACTIVE; + internal long ConnectionId { get; set; } + + public virtual void Start() + { + Logger.Info($"New connection from {RemoteEndPoint}."); + State = SessionStateEnum.WAITING_FOR_TOKEN; + } + + public virtual void Stop(bool isServerStop = false) + { + try + { + Socket?.Shutdown(SocketShutdown.Both); + } + catch { } + finally + { + Socket?.Close(); + Socket?.Dispose(); + } + try + { + CancelToken.Cancel(); + CancelToken.Dispose(); + } + catch + { + } + + IsOnline = false; + } + + public bool SocketConnected() + { + try + { + return !((Socket.Poll(1000, SelectMode.SelectRead) && (Socket.Available == 0)) || !Socket.Connected); + } + catch (Exception e) + { + return false; + } + } + + public void LogPacket(string sendOrRecv, ushort opcode, byte[] payload, PacketFraming framing) + { + if (!ConfigManager.Config.ServerOption.EnableDebug) return; + try + { + //Logger.DebugWriteLine($"{sendOrRecv}: {Enum.GetName(typeof(OpCode), opcode)}({opcode})\r\n{Convert.ToHexString(payload)}"); + if (IgnoreLog.Contains(opcode)) return; + if (!ConfigManager.Config.ServerOption.DebugDetailMessage) throw new Exception(); // go to catch block + var typ = AppDomain.CurrentDomain.GetAssemblies() + .SingleOrDefault(assembly => assembly.GetName().Name == "MikuProto")!.GetTypes() + .First(t => t.Name == $"{LogMap[opcode]}"); //get the type using the packet name + var descriptor = + typ.GetProperty("Descriptor", BindingFlags.Public | BindingFlags.Static)?.GetValue( + null, null) as MessageDescriptor; // get the static property Descriptor + var packet = descriptor?.Parser.ParseFrom(payload); + var formatter = JsonFormatter.Default; + var asJson = formatter.Format(packet); + var output = $"{sendOrRecv}: {LogMap[opcode]}({opcode}) ({framing})\r\n{asJson}"; + if (ConfigManager.Config.ServerOption.DebugMessage) + Logger.Debug(output); + if (DebugFile == "" || !ConfigManager.Config.ServerOption.SavePersonalDebugFile) return; + var sw = GetWriter(); + sw.WriteLine($"[{DateTime.Now:HH:mm:ss}] [GameServer] [DEBUG] " + output); + sw.Flush(); + } + catch + { + var output = $"{sendOrRecv}: {LogMap.GetValueOrDefault(opcode, "UnknownPacket")}({opcode})"; + if (ConfigManager.Config.ServerOption.DebugMessage) + Logger.Debug(output); + if (DebugFile != "" && ConfigManager.Config.ServerOption.SavePersonalDebugFile) + { + var sw = GetWriter(); + sw.WriteLine($"[{DateTime.Now:HH:mm:ss}] [GameServer] [DEBUG] " + output); + sw.Flush(); + } + } + } + + private StreamWriter GetWriter() + { + // Create the file if it doesn't exist + var file = new FileInfo(DebugFile); + if (!file.Exists) + { + Directory.CreateDirectory(file.DirectoryName!); + File.Create(DebugFile).Dispose(); + } + + Writer ??= new StreamWriter(DebugFile, true); + return Writer; + } + + public async Task SendPacket(byte[] packet) + { + try + { + if (Socket.Connected) + { + await Socket.SendAsync( + new ArraySegment(packet), + SocketFlags.None, + CancelToken.Token + ); + } + } + catch + { + // ignore + } + } + + public async Task SendPacket(BasePacket packet, ushort seqNo = 0) + { + // Test + if (packet.CmdId <= 0) + { + Logger.Debug("Tried to send packet with missing cmd id!"); + return; + } + + // DO NOT REMOVE (unless we find a way to validate code before sending to client which I don't think we can) + if (BannedPackets.Contains(packet.CmdId)) return; + LogPacket("Send", packet.CmdId, packet.Body,Framing); + byte[] packetBytes = new PacketCodec().Encode(packet.CmdId, packet.Body,Framing); + try + { + await SendPacket(packetBytes); + } + catch + { + // ignore + } + } + + public async Task SendPacket(int cmdId) + { + await SendPacket(new BasePacket((ushort)cmdId)); + } + + public async Task SendPacket(int cmdId, ushort seqNo) + { + var packet = new BasePacket((ushort)cmdId); + packet.SeqNo = seqNo; + await SendPacket(packet); + } + + public async Task SendPacket(int cmdId, IMessage msg, ushort seqNo = 0) + { + var packet = new BasePacket((ushort)cmdId); + packet.SetData(msg); + packet.SeqNo = seqNo; + await SendPacket(packet); + } +} \ No newline at end of file diff --git a/TcpSharp/SocketListener.cs b/TcpSharp/SocketListener.cs new file mode 100644 index 0000000..668cc41 --- /dev/null +++ b/TcpSharp/SocketListener.cs @@ -0,0 +1,105 @@ +using System.Net.Sockets; +using System.Net; +using MikuSB.Util; +using MikuSB.Internationalization; + +namespace MikuSB.TcpSharp; + +public class SocketListener +{ + private static IPEndPoint? ListenAddress; + private static readonly Logger Logger = new("GameServer"); + + private static Socket? serverSocket; + + public static readonly SortedList Connections = []; + + public static Type BaseConnection { get; set; } = typeof(SocketConnection); + + private static int PORT => ConfigManager.Config.GameServer.Port; + + private static long _nextId = 0; + + public static void StartListener() + { + if (serverSocket != null) + throw new InvalidOperationException("SocketListener already started."); + + ListenAddress = new IPEndPoint(IPAddress.Parse(ConfigManager.Config.GameServer.BindAddress), PORT); + + serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + serverSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + serverSocket.Bind(ListenAddress); + serverSocket.Listen(100); + + Logger.Info(I18NManager.Translate("Server.ServerInfo.ServerRunning", + I18NManager.Translate("Word.Game"), + ConfigManager.Config.GameServer.GetDisplayAddress())); + + _ = Task.Run(AcceptLoop); + } + + private static async Task AcceptLoop() + { + if (serverSocket == null) + throw new InvalidOperationException("Server socket not initialized."); + + try + { + while (true) + { + Socket clientSocket = await serverSocket.AcceptAsync(); + var remote = clientSocket.RemoteEndPoint as IPEndPoint; + + if (remote == null) + { + clientSocket.Close(); + continue; + } + + try + { + var connection = (SocketConnection?)Activator.CreateInstance(BaseConnection, clientSocket, remote); + + if (connection == null) + { + Logger.Error($"Failed to create connection instance from {BaseConnection.Name}"); + clientSocket.Close(); + continue; + } + + var id = Interlocked.Increment(ref _nextId); + connection.ConnectionId = id; + + Connections[id] = connection; + Logger.Info($"Accepted connection #{id} from {remote}"); + } + catch (Exception ex) + { + Logger.Error($"Error creating connection: {ex}"); + clientSocket.Close(); + } + } + } + catch (ObjectDisposedException) + { + Logger.Info("Server stopped listening."); + } + } + + public static SocketConnection? GetConnectionByEndPoint(IPEndPoint ep) + { + Connections.TryGetValue(ep.GetHashCode(), out var conn); + return conn; + } + + public static void UnregisterConnection(SocketConnection socket) + { + if (socket == null) return; + + if (Connections.Remove(socket.ConnectionId)) + { + Logger.Info($"Connection #{socket.ConnectionId} with {socket.RemoteEndPoint} has been closed"); + } + } +} diff --git a/TcpSharp/TcpSharp.csproj b/TcpSharp/TcpSharp.csproj new file mode 100644 index 0000000..68eb1cf --- /dev/null +++ b/TcpSharp/TcpSharp.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + false + TcpSharp + MikuSB.TcpSharp + + + + + + + + + + + +