chore(server/game): remove the old c# server
This commit is contained in:
1 parent
917ab66787
commit
63385b7e87
87 files changed
-1437306
No files matched your search
@@ -1,25 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
|
||||
[*.cs]
|
||||
indent_size = 4
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_prefer_braces = true:warning
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_style_namespace_match_folder = true:suggestion
|
||||
dotnet_diagnostic.IDE0005.severity = warning
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{ps1,bat,cmd}]
|
||||
indent_size = 4
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
artifacts/
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# account data written by srtools and by the server itself
|
||||
data/
|
||||
freesr-data.json
|
||||
|
||||
|
||||
stuff/
|
||||
FetchHotfix/
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/CapySR.Common/CapySR.Common.csproj" />
|
||||
<Project Path="src/CapySR.Data/CapySR.Data.csproj" />
|
||||
<Project Path="src/CapySR.GameServer/CapySR.GameServer.csproj" />
|
||||
<Project Path="src/CapySR.Kcp/CapySR.Kcp.csproj" />
|
||||
<Project Path="src/CapySR.Protocol/CapySR.Protocol.csproj" />
|
||||
<Project Path="src/CapySR.SdkServer/CapySR.SdkServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/CapySR.Tests/CapySR.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tools/">
|
||||
<Project Path="tools/CapySR.ProtoGen/CapySR.ProtoGen.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<RootNamespace>$(MSBuildProjectName)</RootNamespace>
|
||||
<AssemblyName>$(MSBuildProjectName)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,5 +0,0 @@
|
||||
# CapySR - WIP
|
||||
This server is a Work In Progress and might face multiple rewrites and changes.
|
||||
|
||||
|
||||
A Honkai: Star Rail server reimplementation rewritten in C++. This is my first attempt of making a private server in C++, so don't expect much.
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"Dispatch": {
|
||||
"Host": "0.0.0.0",
|
||||
"Port": 21000,
|
||||
"RegionName": "CapySR",
|
||||
"RegionTitle": "CapySR",
|
||||
"PublicHost": "127.0.0.1",
|
||||
"GatewayIp": "127.0.0.1",
|
||||
"GatewayPort": 23301
|
||||
},
|
||||
"GameServer": {
|
||||
"Host": "0.0.0.0",
|
||||
"Port": 23301,
|
||||
"ServerName": "CapySR",
|
||||
"Uid": 25,
|
||||
"Nickname": "CapySR",
|
||||
"Level": 70,
|
||||
"WorldLevel": 6,
|
||||
"StartEntryId": 2050301,
|
||||
"StartTeleportId": 1029,
|
||||
"WorldId": 501,
|
||||
"Signature": "CapySR",
|
||||
"InventoryMode": "all",
|
||||
"BattleSource": "auto",
|
||||
"UnlockAllChallenges": true,
|
||||
"Persist": true
|
||||
},
|
||||
"Hotfix": {
|
||||
"AutoFetch": true,
|
||||
"BypassProxy": true,
|
||||
"CacheFile": "versions.json",
|
||||
"EnableDesignDataUpdate": true,
|
||||
"EnableVideoUpdate": false,
|
||||
"SendResourceUrls": true,
|
||||
"MirrorResources": true
|
||||
},
|
||||
"Data": {
|
||||
"Sources": [
|
||||
"stuff/turnbasedgamedata/ExcelOutput",
|
||||
"stuff/pearl-sr/resources"
|
||||
]
|
||||
},
|
||||
"Client": {
|
||||
"Path": "C:\\Users\\bachk\\Downloads\\StarRail_4.5.52_CN_ReversedRooms"
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"OSBETAWin3.8.51": {
|
||||
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_13490885_bf25e1553b38_e9adc23a6b6710",
|
||||
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_13532881_eb1de9bedabd_c74ab2513059ad",
|
||||
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_13493894_ec7d6d879305_8b2cfd39d6e545",
|
||||
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_13491130_ecfa51828143_4bd221b76c0d9a"
|
||||
},
|
||||
"OSBETAWin4.0.51": {
|
||||
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_14026982_55a517a26691_89300298bf2b2e",
|
||||
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_14036546_28a134460a9a_5805431f75f0ff",
|
||||
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_14028456_7088b08fd431_59194837d90977",
|
||||
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4"
|
||||
},
|
||||
"OSBETAWin4.4.51": {
|
||||
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_15711835_1986f76c287e_9c3d058458edf0",
|
||||
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_15753142_3bb8238470fc_a7cec92c089da7",
|
||||
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_15712109_65eeab393c89_0c116c2699518d",
|
||||
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4"
|
||||
},
|
||||
"CNBETAWin3.5.55": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_11797411_74704086c081_d40a71b41ba492",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_11823033_c9e8c870bcc7_b78f37c36487d0",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_11798340_8c6543a1d6c5_2d60cd9b1584b8",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
},
|
||||
"CNBETAWin3.6.51": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_12066992_f083970b907e_999074cab6dce6",
|
||||
"ex_resource_url": "https://autopatchcn-bhsr.neonteam.dev/design_data/BetaLive/output_12114942_e99cbde25134_e63a6b835f17f9",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_12103115_ee78155e9867_3626f0948d93e2",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_12118783_55113408814f_c874267d04c04a"
|
||||
},
|
||||
"CNBETAWin3.6.52": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_12150127_00d6d096d968_cd76a04beb7ba6",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_12178965_e246796e0bb6_05bcce36cd648b",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_12150614_5279f6d8029a_bdecff99d2d817",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_12164593_8e3fba5163df_b2b6fc46de4c06"
|
||||
},
|
||||
"CNBETAWin3.7.51": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_12579793_48327ff319b5_1b794dd1071e3a",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_12611332_5f583f2f54ae_c04979f13c950d",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_12579929_9566349ee5fb_c6341faaf9b027",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
},
|
||||
"CNBETAWin4.1.51": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_14372365_fa78e03ad599_36972b4e3bb553",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_14372319_01febed213ae_b1f343e62353c2",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_14358961_e055a63b3c34_b199a0234548b5",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_14374543_04d971175704_ab00cb3f54bb00"
|
||||
},
|
||||
"CNBETAWin4.2.51": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_14851990_9aa8ee8de0fa_ec7d1e42f851f1",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_14890490_fcc969a5461f_1f9258ee9068a5",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_14852119_8789f55c1c30_0c0cf5d3c9089b",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_14887825_65010135064b_71536d98b8abbf"
|
||||
},
|
||||
"CNBETAWin4.4.51": {
|
||||
"asset_bundle_url": "https://autopatchos.starrails.com/asb/BetaLive/output_15711835_1986f76c287e_9c3d058458edf0",
|
||||
"ex_resource_url": "https://autopatchos.starrails.com/design_data/BetaLive/output_15753142_3bb8238470fc_a7cec92c089da7",
|
||||
"lua_url": "https://autopatchos.starrails.com/lua/BetaLive/output_15712109_65eeab393c89_0c116c2699518d",
|
||||
"ifix_url": "https://autopatchos.starrails.com/ifix/BetaLive/output_0_40d2ce0253_6d871f8bca6eb4"
|
||||
},
|
||||
"CNBETAWin4.4.52": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_15791026_81432f7ff047_c6833b52b5ef3b",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_15802789_98e925c87f1f_31dd3e9dc8f721",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_15791202_1a65af984218_29844dd244a88d",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
},
|
||||
"CNBETAWin4.4.53": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_15866095_9009036d3e25_973d3ce6b65433",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_15900361_b648af622913_4cf554a7d47922",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_15866255_5a7f51a53ac5_64e185fea5e17f",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_15893225_dd4a15a115ff_7849b83bdd1350"
|
||||
},
|
||||
"CNBETAWin4.5.51": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_16228654_7d9ae69e532c_ff87719729c35f",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_16297504_50bd375a94d3_6512106296b202",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16228766_9f45f1eb6490_9f172521b06e84",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
},
|
||||
"CNBETAWin4.5.52": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_16317320_6a387bae4513_2f3e5644103a8a",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_16343198_0b109c2dab94_dd42d0213ac00f",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16317476_035e0a79224b_ed91499b6ef70b",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
},
|
||||
"CNBETAWin4.5.53": {
|
||||
"asset_bundle_url": "https://autopatchcn.bhsr.com/asb/BetaLive/output_16397599_dc132a900809_7e7a4d9792c13e",
|
||||
"ex_resource_url": "https://autopatchcn.bhsr.com/design_data/BetaLive/output_16399740_f7f17473a4be_cd161900e78e53",
|
||||
"lua_url": "https://autopatchcn.bhsr.com/lua/BetaLive/output_16399471_bf17e3d5c3d7_5605fcd6b48271",
|
||||
"ifix_url": "https://autopatchcn.bhsr.com/ifix/BetaLive/output_0_40d2ce0253_c61ba99f70b885"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start.ps1" %*
|
||||
if errorlevel 1 pause
|
||||
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,97 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.Common;
|
||||
|
||||
// always writes logs/<name>.log so diagnosing never depends on shell redirection
|
||||
public sealed class FileLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly BlockingCollection<string> _queue = new(new ConcurrentQueue<string>(), 8192);
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly Thread _worker;
|
||||
|
||||
public FileLoggerProvider(string name)
|
||||
{
|
||||
var directory = Path.Combine(RepoPaths.Root, "logs");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var file = Path.Combine(directory, $"{name}.log");
|
||||
FilePath = file;
|
||||
_writer = new StreamWriter(file, append: false) { AutoFlush = false };
|
||||
|
||||
_worker = new Thread(Drain) { IsBackground = true, Name = $"{name}-log" };
|
||||
_worker.Start();
|
||||
}
|
||||
|
||||
public string FilePath { get; }
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new FileLogger(this, categoryName);
|
||||
|
||||
internal void Write(string line)
|
||||
{
|
||||
// dropping a line beats blocking the game loop on disk
|
||||
_queue.TryAdd(line);
|
||||
}
|
||||
|
||||
private void Drain()
|
||||
{
|
||||
foreach (var line in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
_writer.WriteLine(line);
|
||||
|
||||
if (_queue.Count == 0)
|
||||
{
|
||||
_writer.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_queue.CompleteAdding();
|
||||
_worker.Join(TimeSpan.FromSeconds(2));
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FileLogger(FileLoggerProvider provider, string category) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var name = category.Split('.')[^1];
|
||||
var line = $"{DateTime.Now:HH:mm:ss} {Short(logLevel)}: {name}: {formatter(state, exception)}";
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
line += Environment.NewLine + exception;
|
||||
}
|
||||
|
||||
provider.Write(line);
|
||||
}
|
||||
|
||||
private static string Short(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "trce",
|
||||
LogLevel.Debug => "dbug",
|
||||
LogLevel.Information => "info",
|
||||
LogLevel.Warning => "WARN",
|
||||
LogLevel.Error => "FAIL",
|
||||
LogLevel.Critical => "CRIT",
|
||||
_ => "????",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace CapySR.Common;
|
||||
|
||||
// A client version reads as a channel followed by dotted numbers: CNBETAWin4.5.53.
|
||||
// Ordering them matters because a beta dispatch stops answering once its window closes,
|
||||
// so a build newer than anything on hand has to fall back to the closest one.
|
||||
public readonly struct GameVersion(string channel, int[] parts) : IComparable<GameVersion>
|
||||
{
|
||||
private static readonly char[] Digits = "0123456789".ToCharArray();
|
||||
|
||||
public string Channel { get; } = channel;
|
||||
|
||||
public int[] Parts { get; } = parts;
|
||||
|
||||
public static bool TryParse(string? version, out GameVersion parsed)
|
||||
{
|
||||
parsed = default;
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var digit = version.IndexOfAny(Digits);
|
||||
|
||||
if (digit <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pieces = version[digit..].Split('.');
|
||||
var parts = new int[pieces.Length];
|
||||
|
||||
for (var i = 0; i < pieces.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(pieces[i], out parts[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parsed = new GameVersion(version[..digit], parts);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SameChannel(GameVersion other) =>
|
||||
Channel.Equals(other.Channel, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int CompareTo(GameVersion other)
|
||||
{
|
||||
var length = Math.Max(Parts.Length, other.Parts.Length);
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var mine = i < Parts.Length ? Parts[i] : 0;
|
||||
var theirs = i < other.Parts.Length ? other.Parts[i] : 0;
|
||||
|
||||
if (mine != theirs)
|
||||
{
|
||||
return mine.CompareTo(theirs);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// the closest build of the same channel: the newest one no later than what was asked
|
||||
// for, or failing that the newest there is. never crosses channels.
|
||||
public static bool TryFindNearest(IEnumerable<string> candidates, string wanted, [NotNullWhen(true)] out string? nearest)
|
||||
{
|
||||
nearest = null;
|
||||
|
||||
if (!TryParse(wanted, out var target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
GameVersion older = default, any = default;
|
||||
var haveOlder = false;
|
||||
var haveAny = false;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!TryParse(candidate, out var version) || !version.SameChannel(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveAny || version.CompareTo(any) > 0)
|
||||
{
|
||||
any = version;
|
||||
haveAny = true;
|
||||
|
||||
if (!haveOlder)
|
||||
{
|
||||
nearest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (version.CompareTo(target) <= 0 && (!haveOlder || version.CompareTo(older) > 0))
|
||||
{
|
||||
older = version;
|
||||
haveOlder = true;
|
||||
nearest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest is not null;
|
||||
}
|
||||
|
||||
public override string ToString() => Channel + string.Join('.', Parts);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace CapySR.Common;
|
||||
|
||||
public sealed class ServerConfig
|
||||
{
|
||||
public DispatchConfig Dispatch { get; set; } = new();
|
||||
|
||||
public GameServerConfig GameServer { get; set; } = new();
|
||||
|
||||
public HotfixConfig Hotfix { get; set; } = new();
|
||||
|
||||
public DataConfig Data { get; set; } = new();
|
||||
|
||||
public static ServerConfig Load(string? path = null)
|
||||
{
|
||||
path ??= Path.Combine(RepoPaths.Root, "config", "config.json");
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(path, optional: true)
|
||||
.AddEnvironmentVariables("CAPYSR_")
|
||||
.Build()
|
||||
.Get<ServerConfig>() ?? new ServerConfig();
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DispatchConfig
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
|
||||
public int Port { get; set; } = 21000;
|
||||
|
||||
public string RegionName { get; set; } = "CapySR";
|
||||
|
||||
public string RegionTitle { get; set; } = "CapySR";
|
||||
|
||||
// what the client is told to hit next; must be reachable from the client
|
||||
public string PublicHost { get; set; } = "127.0.0.1";
|
||||
|
||||
public string GatewayIp { get; set; } = "127.0.0.1";
|
||||
|
||||
public int GatewayPort { get; set; } = 23301;
|
||||
|
||||
public string QueryGatewayUrl => $"http://{PublicHost}:{Port}/query_gateway";
|
||||
}
|
||||
|
||||
public sealed class GameServerConfig
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
|
||||
public int Port { get; set; } = 23301;
|
||||
|
||||
public string ServerName { get; set; } = "CapySR";
|
||||
|
||||
public uint Uid { get; set; } = 25;
|
||||
|
||||
public string Nickname { get; set; } = "CapySR";
|
||||
|
||||
public string Signature { get; set; } = "CapySR";
|
||||
|
||||
// all = every relic and lightcone, equipped = only what is worn, none = empty bag.
|
||||
// "all" makes the client's red-dot filter walk every item, which throws if it cannot
|
||||
// type one of them and freezes the character screen.
|
||||
public string InventoryMode { get; set; } = "equipped";
|
||||
|
||||
// where the player spawns. must be an entry res.json knows, with a matching teleport.
|
||||
public uint StartEntryId { get; set; } = 2050301;
|
||||
|
||||
public uint StartTeleportId { get; set; } = 1029;
|
||||
|
||||
public uint WorldId { get; set; } = 501;
|
||||
|
||||
public uint Level { get; set; } = 70;
|
||||
|
||||
public uint WorldLevel { get; set; } = 6;
|
||||
|
||||
// what a fight plays. "auto" and "srtools" both hand every non-challenge fight to the
|
||||
// battle_config uploaded from srtools, so a calyx runs whatever stage is configured -
|
||||
// that is how an older MOC/PF/AS floor gets tested while F4 keeps the current rotation.
|
||||
// "stage" always plays the encounter's own stage instead.
|
||||
public string BattleSource { get; set; } = "auto";
|
||||
|
||||
// report every MOC/PF/AS floor as cleared so none of them sit locked behind the one
|
||||
// before it. floors that have actually been played report their real result.
|
||||
public bool UnlockAllChallenges { get; set; } = true;
|
||||
|
||||
// remember lineups, position and challenge records in data/player.json
|
||||
public bool Persist { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class HotfixConfig
|
||||
{
|
||||
public bool AutoFetch { get; set; } = true;
|
||||
|
||||
// local MITM proxies throttle the CDN badly; go direct
|
||||
public bool BypassProxy { get; set; } = true;
|
||||
|
||||
public string CacheFile { get; set; } = "versions.json";
|
||||
|
||||
// Telling a pre-packaged client to update makes it fetch design data and lua from the
|
||||
// official CDN. autopatchcn.bhsr.com is China-only, so off-mainland clients time out and
|
||||
// hang on the loading bar. Packaged clients already ship these files.
|
||||
public bool EnableDesignDataUpdate { get; set; }
|
||||
|
||||
public bool EnableVideoUpdate { get; set; }
|
||||
|
||||
// blank the resource urls entirely so the client cannot even try
|
||||
public bool SendResourceUrls { get; set; } = true;
|
||||
|
||||
// serve the cdn through our own host, for clients whose dns lands on an unreachable edge
|
||||
public bool MirrorResources { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class DataConfig
|
||||
{
|
||||
// priority order; the first source to define a row id keeps it.
|
||||
// left empty so the config binder replaces rather than appends to a default.
|
||||
public List<string> Sources { get; set; } = [];
|
||||
|
||||
private static readonly string[] DefaultSources =
|
||||
[
|
||||
"stuff/turnbasedgamedata/ExcelOutput",
|
||||
"stuff/pearl-sr/resources",
|
||||
];
|
||||
|
||||
public IEnumerable<string> ResolvedSources => (Sources.Count > 0 ? Sources : DefaultSources.ToList())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(s => Path.IsPathRooted(s) ? s : Path.Combine(RepoPaths.Root, s));
|
||||
}
|
||||
|
||||
public static class RepoPaths
|
||||
{
|
||||
public static string Root { get; } = Find();
|
||||
|
||||
public static string Data => Path.Combine(Root, "data");
|
||||
|
||||
public static string Config => Path.Combine(Root, "config");
|
||||
|
||||
private static string Find()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "CapySR.slnx")) ||
|
||||
File.Exists(Path.Combine(dir.FullName, "CapySR.sln")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,109 +0,0 @@
|
||||
namespace CapySR.Data.Excel;
|
||||
|
||||
// Tables that decide what a fight is made of: where it is started from, which stage that
|
||||
// resolves to, and who is lent to the player for it.
|
||||
|
||||
// Calyx. one row per (ID, WorldLevel); WorldLevel is absent from the level-0 row
|
||||
public sealed class CocoonExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint WorldLevel { get; set; }
|
||||
|
||||
public uint StageID { get; set; }
|
||||
|
||||
public List<uint> StageIDList { get; set; } = [];
|
||||
|
||||
public uint StaminaCost { get; set; }
|
||||
|
||||
public uint MappingInfoID { get; set; }
|
||||
|
||||
public string CocoonType { get; set; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<uint> Stages => StageIDList.Count > 0 ? StageIDList : StageID != 0 ? [StageID] : [];
|
||||
}
|
||||
|
||||
// Stagnant Shadow / Cavern of Corrosion. same (ID, WorldLevel) shape as cocoons
|
||||
public sealed class FarmElementExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint WorldLevel { get; set; }
|
||||
|
||||
public uint StageID { get; set; }
|
||||
|
||||
public uint StaminaCost { get; set; }
|
||||
|
||||
public uint MappingInfoID { get; set; }
|
||||
}
|
||||
|
||||
// an overworld monster's EventID resolves to a stage through here, per world level
|
||||
public sealed class PlaneEventExcel
|
||||
{
|
||||
public uint EventID { get; set; }
|
||||
|
||||
public uint WorldLevel { get; set; }
|
||||
|
||||
public uint StageID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BattleCollegeExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint StageID { get; set; }
|
||||
|
||||
public List<uint> TrialAvatarList { get; set; } = [];
|
||||
}
|
||||
|
||||
// the win conditions a battle reports progress against
|
||||
public sealed class BattleTargetExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public uint TargetParam { get; set; }
|
||||
}
|
||||
|
||||
// PF floors with an invading monster: the stage carries an extra buff 3034000 + id
|
||||
public sealed class StageInvasionExcel
|
||||
{
|
||||
public uint StageID { get; set; }
|
||||
|
||||
public uint InvasionID { get; set; }
|
||||
}
|
||||
|
||||
// trial characters lent to a stage: battle college, story fights, ...
|
||||
public sealed class SpecialAvatarExcel
|
||||
{
|
||||
public uint SpecialAvatarID { get; set; }
|
||||
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
public uint PlayerID { get; set; }
|
||||
|
||||
public uint Level { get; set; }
|
||||
|
||||
public uint Promotion { get; set; }
|
||||
|
||||
public uint EquipmentID { get; set; }
|
||||
|
||||
public uint EquipmentLevel { get; set; }
|
||||
|
||||
public uint EquipmentPromotion { get; set; }
|
||||
|
||||
public uint EquipmentRank { get; set; }
|
||||
|
||||
public bool IsUseWorldLevel { get; set; }
|
||||
}
|
||||
|
||||
public sealed class NpcMonsterExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public string Rank { get; set; } = string.Empty;
|
||||
|
||||
// techniques that one-shot fodder (Acheron, Phainon, ...) skip the fight for these
|
||||
public bool IsFodder => Rank is "Minion" or "MinionLv2";
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
namespace CapySR.Data.Excel;
|
||||
|
||||
// MOC / PF / AS floors. id ranges: <20000 MOC, 20000-30000 PF, >30000 AS.
|
||||
// dimbreath keeps them in three files with one shape; the beta dump merges them.
|
||||
public sealed class ChallengeMazeExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint GroupID { get; set; }
|
||||
|
||||
public uint Floor { get; set; }
|
||||
|
||||
public uint MapEntranceID { get; set; }
|
||||
|
||||
public uint MapEntranceID2 { get; set; }
|
||||
|
||||
public uint MazeGroupID1 { get; set; }
|
||||
|
||||
public uint? MazeGroupID2 { get; set; }
|
||||
|
||||
public uint MazeBuffID { get; set; }
|
||||
|
||||
public uint ChallengeCountDown { get; set; }
|
||||
|
||||
public uint StageNum { get; set; }
|
||||
|
||||
public List<uint> ChallengeTargetID { get; set; } = [];
|
||||
|
||||
public List<uint> NpcMonsterIDList1 { get; set; } = [];
|
||||
|
||||
public List<uint> NpcMonsterIDList2 { get; set; } = [];
|
||||
|
||||
public List<uint> EventIDList1 { get; set; } = [];
|
||||
|
||||
public List<uint> EventIDList2 { get; set; } = [];
|
||||
|
||||
public ChallengeKind Kind => ID switch
|
||||
{
|
||||
> 30000 => ChallengeKind.ApocalypticShadow,
|
||||
> 20000 => ChallengeKind.PureFiction,
|
||||
_ => ChallengeKind.MemoryOfChaos,
|
||||
};
|
||||
|
||||
public bool HasSecondNode => MazeGroupID2 is > 0 && NpcMonsterIDList2.Count > 0 && EventIDList2.Count > 0;
|
||||
|
||||
// StageNum is missing from the trimmed beta rows; fall back to what the node data says
|
||||
public int NodeCount => StageNum > 0 ? (int)Math.Min(StageNum, 2) : HasSecondNode ? 2 : 1;
|
||||
}
|
||||
|
||||
public enum ChallengeKind
|
||||
{
|
||||
MemoryOfChaos,
|
||||
PureFiction,
|
||||
ApocalypticShadow,
|
||||
}
|
||||
|
||||
// Anomaly Arbitration. MapEntranceID/MazeGroupID/NpcMonsterIDList only exist in the beta dump.
|
||||
public sealed class ChallengePeakExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint MapEntranceID { get; set; }
|
||||
|
||||
public uint MazeGroupID { get; set; }
|
||||
|
||||
public List<uint> NpcMonsterIDList { get; set; } = [];
|
||||
|
||||
public List<uint> EventIDList { get; set; } = [];
|
||||
|
||||
public List<uint> TagList { get; set; } = [];
|
||||
|
||||
public List<uint> NormalTargetList { get; set; } = [];
|
||||
|
||||
public List<string> DamageType { get; set; } = [];
|
||||
|
||||
public bool IsPlayable => MapEntranceID > 0 && NpcMonsterIDList.Count > 0 && EventIDList.Count > 0;
|
||||
}
|
||||
|
||||
public sealed class ChallengePeakBossExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public List<uint> BuffList { get; set; } = [];
|
||||
|
||||
public List<uint> HardTagList { get; set; } = [];
|
||||
|
||||
public List<uint> HardEventIDList { get; set; } = [];
|
||||
|
||||
public uint HardTarget { get; set; }
|
||||
}
|
||||
|
||||
// PF extras: the score targets the battle reports against
|
||||
public sealed class ChallengeStoryExtraExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint TurnLimit { get; set; }
|
||||
|
||||
public List<uint> BattleTargetID { get; set; } = [];
|
||||
|
||||
public uint ClearScore { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChallengeBossExtraExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint MonsterID1 { get; set; }
|
||||
|
||||
public uint MonsterID2 { get; set; }
|
||||
}
|
||||
|
||||
// star conditions for MOC (ROUNDS_LEFT / DEAD_AVATAR) and PF/AS (TOTAL_SCORE)
|
||||
public sealed class ChallengeTargetExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public string ChallengeTargetType { get; set; } = string.Empty;
|
||||
|
||||
public uint ChallengeTargetParam1 { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChallengeGroupExcel
|
||||
{
|
||||
public uint GroupID { get; set; }
|
||||
|
||||
public string ChallengeGroupType { get; set; } = string.Empty;
|
||||
|
||||
public uint RewardLineGroupID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MapEntranceExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint PlaneID { get; set; }
|
||||
|
||||
public uint FloorID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MazePlaneExcel
|
||||
{
|
||||
public uint PlaneID { get; set; }
|
||||
|
||||
public uint WorldID { get; set; }
|
||||
|
||||
public uint ChallengePlaneID { get; set; }
|
||||
|
||||
public List<uint> FloorIDList { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChallengePeakGroupExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint BossLevelID { get; set; }
|
||||
|
||||
public List<uint> PreLevelIDList { get; set; } = [];
|
||||
|
||||
public IEnumerable<uint> AllStages => BossLevelID > 0 ? PreLevelIDList.Append(BossLevelID) : PreLevelIDList;
|
||||
}
|
||||
|
||||
public sealed class MainMissionExcel
|
||||
{
|
||||
public uint MainMissionID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TutorialExcel
|
||||
{
|
||||
public uint TutorialID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TutorialGuideGroupExcel
|
||||
{
|
||||
public uint GroupID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GachaCeilingExcel
|
||||
{
|
||||
public string GachaType { get; set; } = string.Empty;
|
||||
|
||||
public List<uint> CeilingItemList { get; set; } = [];
|
||||
}
|
||||
|
||||
// maps an item id to its main/sub type. the client NPEs on an item it cannot type.
|
||||
public sealed class ItemConfigExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MultiPathAvatarExcel
|
||||
{
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
public uint BaseAvatarID { get; set; }
|
||||
|
||||
public string Gender { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CapySR.Data.Excel;
|
||||
|
||||
// dimbreath wraps scalars as {"Value": n}; the zig servers' dumps flatten them to bare numbers
|
||||
[JsonConverter(typeof(ExcelValueConverter))]
|
||||
public readonly record struct ExcelValue(long Value)
|
||||
{
|
||||
public static implicit operator long(ExcelValue v) => v.Value;
|
||||
|
||||
public uint AsUInt32() => (uint)Value;
|
||||
}
|
||||
|
||||
internal sealed class ExcelValueConverter : JsonConverter<ExcelValue>
|
||||
{
|
||||
public override ExcelValue Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
|
||||
{
|
||||
switch (reader.TokenType)
|
||||
{
|
||||
case JsonTokenType.Number:
|
||||
return new ExcelValue(reader.TryGetInt64(out var n) ? n : (long)reader.GetDouble());
|
||||
|
||||
case JsonTokenType.String:
|
||||
return new ExcelValue(long.TryParse(reader.GetString(), out var parsed) ? parsed : 0);
|
||||
|
||||
case JsonTokenType.StartObject:
|
||||
long value = 0;
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var name = reader.GetString();
|
||||
reader.Read();
|
||||
|
||||
if (name is "Value" or "value" && reader.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
value = reader.TryGetInt64(out var v) ? v : (long)reader.GetDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip();
|
||||
}
|
||||
}
|
||||
|
||||
return new ExcelValue(value);
|
||||
|
||||
default:
|
||||
reader.Skip();
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, ExcelValue value, JsonSerializerOptions options) =>
|
||||
writer.WriteNumberValue(value.Value);
|
||||
}
|
||||
|
||||
public sealed class AvatarExcel
|
||||
{
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
public string Rarity { get; set; } = string.Empty;
|
||||
|
||||
public string DamageType { get; set; } = string.Empty;
|
||||
|
||||
public string AvatarBaseType { get; set; } = string.Empty;
|
||||
|
||||
public ExcelValue SPNeed { get; set; }
|
||||
|
||||
public List<uint> RankIDList { get; set; } = [];
|
||||
|
||||
public bool Release { get; set; }
|
||||
|
||||
public uint SpMax => SPNeed.AsUInt32();
|
||||
}
|
||||
|
||||
public sealed class AvatarSkillTreeExcel
|
||||
{
|
||||
public uint PointID { get; set; }
|
||||
|
||||
public uint Level { get; set; }
|
||||
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
public uint MaxLevel { get; set; }
|
||||
|
||||
// "Point01"
|
||||
public string AnchorType { get; set; } = string.Empty;
|
||||
|
||||
// AvatarPathData.point_id is the anchor index, not the raw point id
|
||||
public uint AnchorIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var start = AnchorType.Length;
|
||||
|
||||
while (start > 0 && char.IsAsciiDigit(AnchorType[start - 1]))
|
||||
{
|
||||
start--;
|
||||
}
|
||||
|
||||
return start < AnchorType.Length && uint.TryParse(AnchorType.AsSpan(start), out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ID is the avatar; DefaultMazeBuffIDList[0] is the buff the client expects when
|
||||
// that avatar starts a battle, with SkillIndex passed as a dynamic value
|
||||
public sealed class AvatarDefaultMazeBuffExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint SkillIndex { get; set; }
|
||||
|
||||
public List<uint> DefaultMazeBuffIDList { get; set; } = [];
|
||||
|
||||
public uint BuffId => DefaultMazeBuffIDList.Count > 0 ? DefaultMazeBuffIDList[0] : 0;
|
||||
}
|
||||
|
||||
public sealed class AvatarGlobalBuffExcel
|
||||
{
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
}
|
||||
|
||||
// one profile portrait per character
|
||||
public sealed class HeadIconExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public uint AvatarID { get; set; }
|
||||
}
|
||||
|
||||
// the ten avatars with an enhanced technique state
|
||||
public sealed class AvatarConfigEnhancedExcel
|
||||
{
|
||||
public uint AvatarID { get; set; }
|
||||
|
||||
public uint EnhancedID { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AvatarMazeBuffExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public string ModifierName { get; set; } = string.Empty;
|
||||
|
||||
// ADV_GlobalSkill_Maze_* buffs apply even when the avatar is not in the lineup
|
||||
public bool IsGlobal => ModifierName.StartsWith("ADV_GlobalSkill_Maze", StringComparison.Ordinal);
|
||||
|
||||
public uint OwnerAvatarId => ID / 100;
|
||||
}
|
||||
|
||||
public sealed class EquipmentExcel
|
||||
{
|
||||
public uint EquipmentID { get; set; }
|
||||
|
||||
// "CombatPowerLightconeRarity3"
|
||||
public string Rarity { get; set; } = string.Empty;
|
||||
|
||||
public uint MaxPromotion { get; set; }
|
||||
|
||||
public uint MaxRank { get; set; }
|
||||
|
||||
public bool Release { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MonsterExcel
|
||||
{
|
||||
public uint MonsterID { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public sealed class StageExcel
|
||||
{
|
||||
public uint StageID { get; set; }
|
||||
|
||||
public string StageType { get; set; } = string.Empty;
|
||||
|
||||
public uint Level { get; set; }
|
||||
|
||||
public uint HardLevelGroup { get; set; }
|
||||
|
||||
public uint EliteGroup { get; set; }
|
||||
|
||||
public bool Release { get; set; }
|
||||
|
||||
[JsonConverter(typeof(MonsterWaveConverter))]
|
||||
public List<List<uint>> MonsterList { get; set; } = [];
|
||||
|
||||
[JsonConverter(typeof(StageConfigDataConverter))]
|
||||
public Dictionary<string, string> StageConfigData { get; set; } = [];
|
||||
|
||||
// SpecialAvatar ids lent to the player for this fight
|
||||
public List<uint> TrialAvatarList { get; set; } = [];
|
||||
}
|
||||
|
||||
// dimbreath: [{ "Monster0": id, ... }] per wave. trimmed dumps: [[id, id, ...]] per wave.
|
||||
internal sealed class MonsterWaveConverter : JsonConverter<List<List<uint>>>
|
||||
{
|
||||
public override List<List<uint>> Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var waves = new List<List<uint>>();
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
reader.Skip();
|
||||
return waves;
|
||||
}
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.StartArray)
|
||||
{
|
||||
var flat = new List<uint>();
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
flat.Add(reader.GetUInt32());
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip();
|
||||
}
|
||||
}
|
||||
|
||||
waves.Add(flat);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
reader.Skip();
|
||||
continue;
|
||||
}
|
||||
|
||||
var wave = new SortedDictionary<string, uint>(StringComparer.Ordinal);
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
var name = reader.GetString()!;
|
||||
reader.Read();
|
||||
|
||||
if (reader.TokenType == JsonTokenType.Number)
|
||||
{
|
||||
wave[name] = reader.GetUInt32();
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.Skip();
|
||||
}
|
||||
}
|
||||
|
||||
waves.Add([.. wave.Values]);
|
||||
}
|
||||
|
||||
return waves;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, List<List<uint>> value, JsonSerializerOptions options) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
// entries look like { "<obf>": "_Wave", "<obf>": "1" } - the key names are obfuscated
|
||||
// and reshuffle every version, so key off the value that starts with '_'
|
||||
internal sealed class StageConfigDataConverter : JsonConverter<Dictionary<string, string>>
|
||||
{
|
||||
public override Dictionary<string, string> Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
{
|
||||
reader.Skip();
|
||||
return result;
|
||||
}
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
reader.Skip();
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = new List<string>(2);
|
||||
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
|
||||
{
|
||||
reader.Read();
|
||||
values.Add(reader.TokenType == JsonTokenType.String ? reader.GetString()! : string.Empty);
|
||||
}
|
||||
|
||||
var key = values.FirstOrDefault(v => v.StartsWith('_'));
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
result[key] = values.FirstOrDefault(v => !v.StartsWith('_')) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, Dictionary<string, string> value, JsonSerializerOptions options) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
namespace CapySR.Data.Excel;
|
||||
|
||||
// res.json / Anchor.json ship with the zig servers, not with the ExcelOutput dump
|
||||
public sealed class SceneResourceExcel
|
||||
{
|
||||
public uint EntryID { get; set; }
|
||||
|
||||
public uint PlaneID { get; set; }
|
||||
|
||||
public List<ScenePlacement> Monsters { get; set; } = [];
|
||||
|
||||
public List<ScenePropPlacement> Props { get; set; } = [];
|
||||
|
||||
public List<SceneTeleport> Teleports { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ScenePlacement
|
||||
{
|
||||
public uint GroupId { get; set; }
|
||||
|
||||
public uint InstId { get; set; }
|
||||
|
||||
public uint MonsterId { get; set; }
|
||||
|
||||
public uint EventId { get; set; }
|
||||
|
||||
public ResVector Pos { get; set; } = new();
|
||||
|
||||
public ResVector Rot { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ScenePropPlacement
|
||||
{
|
||||
public uint GroupId { get; set; }
|
||||
|
||||
public uint InstId { get; set; }
|
||||
|
||||
public uint PropId { get; set; }
|
||||
|
||||
public uint PropState { get; set; }
|
||||
|
||||
public ResVector Pos { get; set; } = new();
|
||||
|
||||
public ResVector Rot { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SceneTeleport
|
||||
{
|
||||
public uint TeleportId { get; set; }
|
||||
|
||||
public ResVector Pos { get; set; } = new();
|
||||
|
||||
public ResVector Rot { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AnchorExcel
|
||||
{
|
||||
public uint EntryID { get; set; }
|
||||
|
||||
public List<AnchorPoint> Anchor { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class AnchorPoint
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public ResVector Pos { get; set; } = new();
|
||||
|
||||
public ResVector Rot { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ResVector
|
||||
{
|
||||
public int X { get; set; }
|
||||
|
||||
public int Y { get; set; }
|
||||
|
||||
public int Z { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MazePropExcel
|
||||
{
|
||||
public uint ID { get; set; }
|
||||
|
||||
public string PropType { get; set; } = string.Empty;
|
||||
|
||||
public string ConfigEntityPath { get; set; } = string.Empty;
|
||||
|
||||
public string JsonPath { get; set; } = string.Empty;
|
||||
|
||||
public bool IsDestructible => PropType is "PROP_DESTRUCT" or "PROP_NO_REWARD_DESTRUCT";
|
||||
|
||||
// space anchors and sustenance anchors
|
||||
public bool IsSpring => PropType == "PROP_SPRING";
|
||||
|
||||
// the orbs that refund technique points / heal when smashed; only the entity name tells
|
||||
public bool IsMpRecover => IsDestructible && (Mentions("MPBox") || Mentions("MPRecover"));
|
||||
|
||||
public bool IsHpRecover => IsDestructible && (Mentions("HPBox") || Mentions("HPRecover"));
|
||||
|
||||
private bool Mentions(string token) =>
|
||||
ConfigEntityPath.Contains(token, StringComparison.OrdinalIgnoreCase) ||
|
||||
JsonPath.Contains(token, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class FloorSavedValuesExcel
|
||||
{
|
||||
public uint FloorID { get; set; }
|
||||
|
||||
public List<FloorSavedValue> SavedValues { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class FloorSavedValue
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int MaxValue { get; set; }
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace CapySR.Data;
|
||||
|
||||
public sealed partial class GameData
|
||||
{
|
||||
// Rows merge field by field across sources, not wholesale. Neither source is a superset:
|
||||
// pearl's StageConfig rows drop StageType, while its ChallengePeakConfig carries
|
||||
// MapEntranceID/NpcMonsterIDList that Dimbreath has no column for at all. Earlier sources
|
||||
// win on any field they define; later ones only fill in what is missing.
|
||||
private Dictionary<uint, T> Merge<T>(string fileName, Func<JsonObject, uint?> key, bool required = true)
|
||||
{
|
||||
var merged = new Dictionary<uint, JsonObject>();
|
||||
var found = false;
|
||||
|
||||
foreach (var root in _sources)
|
||||
{
|
||||
var path = Path.Combine(root, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
found = true;
|
||||
|
||||
foreach (var row in ReadRows(path))
|
||||
{
|
||||
if (key(row) is not { } id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (merged.TryGetValue(id, out var existing))
|
||||
{
|
||||
FillMissing(existing, row);
|
||||
}
|
||||
else
|
||||
{
|
||||
merged[id] = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && required)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"no source provides {fileName} (looked in {string.Join(", ", _sources)})");
|
||||
}
|
||||
|
||||
var result = new Dictionary<uint, T>(merged.Count);
|
||||
|
||||
foreach (var (id, row) in merged)
|
||||
{
|
||||
if (row.Deserialize<T>(NodeOptions) is { } value)
|
||||
{
|
||||
result[id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void FillMissing(JsonObject target, JsonObject extra)
|
||||
{
|
||||
foreach (var (name, value) in extra)
|
||||
{
|
||||
if (!target.TryGetPropertyValue(name, out var existing) || existing is null)
|
||||
{
|
||||
target[name] = value?.DeepClone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<JsonObject> ReadRows(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
var node = JsonNode.Parse(stream);
|
||||
|
||||
// dimbreath writes a bare array; the zig servers wrap it as {"avatar_config": [...]}
|
||||
var array = node as JsonArray;
|
||||
|
||||
if (array is null && node is JsonObject wrapper)
|
||||
{
|
||||
foreach (var (_, value) in wrapper)
|
||||
{
|
||||
if (value is JsonArray inner)
|
||||
{
|
||||
array = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (array is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rows = new List<JsonObject>(array.Count);
|
||||
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is JsonObject row)
|
||||
{
|
||||
rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static uint? UInt(JsonObject row, string name) =>
|
||||
row.TryGetPropertyValue(name, out var node) && node is not null && node.GetValueKind() is JsonValueKind.Number
|
||||
? node.GetValue<uint>()
|
||||
: null;
|
||||
|
||||
private static string? Str(JsonObject row, string name) =>
|
||||
row.TryGetPropertyValue(name, out var node) && node is not null && node.GetValueKind() is JsonValueKind.String
|
||||
? node.GetValue<string>()
|
||||
: null;
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using CapySR.Data.Excel;
|
||||
|
||||
namespace CapySR.Data;
|
||||
|
||||
// sources are listed in priority order and merged per row id: the first source to define a
|
||||
// row keeps it, later ones only fill gaps. beta dumps are trimmed extracts, so letting them
|
||||
// replace full prod rows would silently drop fields like StageType.
|
||||
public sealed partial class GameData
|
||||
{
|
||||
private static readonly JsonSerializerOptions NodeOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
private readonly string[] _sources;
|
||||
|
||||
private GameData(string[] sources) => _sources = sources;
|
||||
|
||||
public IReadOnlyList<string> Sources => _sources;
|
||||
|
||||
public IReadOnlyDictionary<uint, AvatarExcel> Avatars { get; private set; } = new Dictionary<uint, AvatarExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, List<AvatarSkillTreeExcel>> SkillTrees { get; private set; } =
|
||||
new Dictionary<uint, List<AvatarSkillTreeExcel>>();
|
||||
|
||||
public IReadOnlyDictionary<uint, AvatarDefaultMazeBuffExcel> DefaultMazeBuffs { get; private set; } =
|
||||
new Dictionary<uint, AvatarDefaultMazeBuffExcel>();
|
||||
|
||||
public IReadOnlyList<AvatarGlobalBuffExcel> GlobalBuffs { get; private set; } = [];
|
||||
|
||||
public IReadOnlyDictionary<uint, StageExcel> Stages { get; private set; } = new Dictionary<uint, StageExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, MonsterExcel> Monsters { get; private set; } = new Dictionary<uint, MonsterExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, EquipmentExcel> Equipment { get; private set; } = new Dictionary<uint, EquipmentExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, uint> EnhancedAvatars { get; private set; } = new Dictionary<uint, uint>();
|
||||
|
||||
public IReadOnlyDictionary<uint, AvatarMazeBuffExcel> MazeBuffs { get; private set; } =
|
||||
new Dictionary<uint, AvatarMazeBuffExcel>();
|
||||
|
||||
// (avatar, buff) pairs that apply from the roster rather than the lineup
|
||||
public IReadOnlyList<(uint AvatarId, uint BuffId)> GlobalMazeBuffs { get; private set; } = [];
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengeMazeExcel> Challenges { get; private set; } =
|
||||
new Dictionary<uint, ChallengeMazeExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengeStoryExtraExcel> ChallengeStoryExtras { get; private set; } =
|
||||
new Dictionary<uint, ChallengeStoryExtraExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengeBossExtraExcel> ChallengeBossExtras { get; private set; } =
|
||||
new Dictionary<uint, ChallengeBossExtraExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengeTargetExcel> ChallengeTargets { get; private set; } =
|
||||
new Dictionary<uint, ChallengeTargetExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengeGroupExcel> ChallengeGroups { get; private set; } =
|
||||
new Dictionary<uint, ChallengeGroupExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, BattleTargetExcel> BattleTargets { get; private set; } =
|
||||
new Dictionary<uint, BattleTargetExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengePeakExcel> ChallengePeaks { get; private set; } =
|
||||
new Dictionary<uint, ChallengePeakExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengePeakBossExcel> ChallengePeakBosses { get; private set; } =
|
||||
new Dictionary<uint, ChallengePeakBossExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, MapEntranceExcel> MapEntrances { get; private set; } =
|
||||
new Dictionary<uint, MapEntranceExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, MazePlaneExcel> MazePlanes { get; private set; } =
|
||||
new Dictionary<uint, MazePlaneExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, ChallengePeakGroupExcel> ChallengePeakGroups { get; private set; } =
|
||||
new Dictionary<uint, ChallengePeakGroupExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, SceneResourceExcel> Scenes { get; private set; } =
|
||||
new Dictionary<uint, SceneResourceExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, AnchorExcel> Anchors { get; private set; } =
|
||||
new Dictionary<uint, AnchorExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, FloorSavedValuesExcel> FloorSavedValues { get; private set; } =
|
||||
new Dictionary<uint, FloorSavedValuesExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, MainMissionExcel> MainMissions { get; private set; } =
|
||||
new Dictionary<uint, MainMissionExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, CocoonExcel> Cocoons { get; private set; } = new Dictionary<uint, CocoonExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, FarmElementExcel> FarmElements { get; private set; } =
|
||||
new Dictionary<uint, FarmElementExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, PlaneEventExcel> PlaneEvents { get; private set; } =
|
||||
new Dictionary<uint, PlaneEventExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, BattleCollegeExcel> BattleColleges { get; private set; } =
|
||||
new Dictionary<uint, BattleCollegeExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, SpecialAvatarExcel> SpecialAvatars { get; private set; } =
|
||||
new Dictionary<uint, SpecialAvatarExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, NpcMonsterExcel> NpcMonsters { get; private set; } =
|
||||
new Dictionary<uint, NpcMonsterExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, MazePropExcel> Props { get; private set; } = new Dictionary<uint, MazePropExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, HeadIconExcel> HeadIcons { get; private set; } = new Dictionary<uint, HeadIconExcel>();
|
||||
|
||||
public IReadOnlyDictionary<uint, StageInvasionExcel> StageInvasions { get; private set; } = new Dictionary<uint, StageInvasionExcel>();
|
||||
|
||||
public IReadOnlySet<uint> DestructiblePropIds { get; private set; } = new HashSet<uint>();
|
||||
|
||||
public IReadOnlyList<uint> TutorialIds { get; private set; } = [];
|
||||
|
||||
public IReadOnlyList<uint> TutorialGuideIds { get; private set; } = [];
|
||||
|
||||
// the standard banner's guaranteed 5* pool
|
||||
public IReadOnlyList<uint> GachaCeilingAvatars { get; private set; } = [];
|
||||
|
||||
// every relic / lightcone id the client can resolve a type for
|
||||
public IReadOnlySet<uint> KnownRelicIds { get; private set; } = new HashSet<uint>();
|
||||
|
||||
public IReadOnlySet<uint> KnownLightconeIds { get; private set; } = new HashSet<uint>();
|
||||
|
||||
// path avatar id -> the base id the client files it under (8002 -> 8001, 1224 -> 1001)
|
||||
public IReadOnlyDictionary<uint, uint> BaseAvatarIds { get; private set; } = new Dictionary<uint, uint>();
|
||||
|
||||
public IReadOnlySet<uint> MultiPathAvatarIds { get; private set; } = new HashSet<uint>();
|
||||
|
||||
public static GameData Load(IEnumerable<string> sources, Action<string>? log = null)
|
||||
{
|
||||
log ??= _ => { };
|
||||
|
||||
var wanted = sources.ToArray();
|
||||
var roots = wanted.Where(Directory.Exists).ToArray();
|
||||
|
||||
if (roots.Length == 0)
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
"no game data sources found. Clone Dimbreath/turnbasedgamedata and set Data:Sources " +
|
||||
"in config/config.json to its ExcelOutput folder. Looked in: " +
|
||||
(wanted.Length > 0 ? string.Join(", ", wanted) : "nowhere, Data:Sources is empty"));
|
||||
}
|
||||
|
||||
var data = new GameData(roots);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
data.Avatars = data.Merge<AvatarExcel>("AvatarConfig.json", r => UInt(r, "AvatarID"));
|
||||
data.DefaultMazeBuffs = data.Merge<AvatarDefaultMazeBuffExcel>("AvatarDefaultMazeBuff.json", r => UInt(r, "ID"), required: false);
|
||||
data.Stages = data.Merge<StageExcel>("StageConfig.json", r => UInt(r, "StageID"));
|
||||
data.Monsters = data.Merge<MonsterExcel>("MonsterConfig.json", r => UInt(r, "MonsterID"), required: false);
|
||||
data.Equipment = data.Merge<EquipmentExcel>("EquipmentConfig.json", r => UInt(r, "EquipmentID"), required: false);
|
||||
|
||||
data.SkillTrees = data
|
||||
// one row per (point, level)
|
||||
.Merge<AvatarSkillTreeExcel>("AvatarSkillTreeConfig.json", r => (UInt(r, "PointID") * 100u) + (UInt(r, "Level") ?? 0))
|
||||
.Values
|
||||
.GroupBy(p => p.AvatarID)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
data.GlobalBuffs = [.. data.Merge<AvatarGlobalBuffExcel>("AvatarGlobalBuffConfig.json", r => UInt(r, "AvatarID"), required: false).Values];
|
||||
|
||||
data.EnhancedAvatars = data
|
||||
.Merge<AvatarConfigEnhancedExcel>("AvatarConfigEnhanced.json", r => UInt(r, "AvatarID"), required: false)
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value.EnhancedID == 0 ? 1u : kv.Value.EnhancedID);
|
||||
|
||||
data.MazeBuffs = data.Merge<AvatarMazeBuffExcel>("AvatarMazeBuff.json", r => UInt(r, "ID"), required: false);
|
||||
|
||||
// prod splits the three modes into three files with one shape; the beta dump merges them
|
||||
var challenges = data.Merge<ChallengeMazeExcel>("ChallengeMazeConfig.json", r => UInt(r, "ID"), required: false);
|
||||
foreach (var file in new[] { "ChallengeStoryMazeConfig.json", "ChallengeBossMazeConfig.json" })
|
||||
{
|
||||
foreach (var (id, row) in data.Merge<ChallengeMazeExcel>(file, r => UInt(r, "ID"), required: false))
|
||||
{
|
||||
challenges.TryAdd(id, row);
|
||||
}
|
||||
}
|
||||
|
||||
data.Challenges = challenges;
|
||||
data.ChallengeStoryExtras = data.Merge<ChallengeStoryExtraExcel>("ChallengeStoryMazeExtra.json", r => UInt(r, "ID"), required: false);
|
||||
data.ChallengeBossExtras = data.Merge<ChallengeBossExtraExcel>("ChallengeBossMazeExtra.json", r => UInt(r, "ID"), required: false);
|
||||
data.ChallengeGroups = data.Merge<ChallengeGroupExcel>("ChallengeGroupConfig.json", r => UInt(r, "GroupID"), required: false);
|
||||
|
||||
var targets = data.Merge<ChallengeTargetExcel>("ChallengeTargetConfig.json", r => UInt(r, "ID"), required: false);
|
||||
foreach (var file in new[] { "ChallengeStoryTargetConfig.json", "ChallengeBossTargetConfig.json" })
|
||||
{
|
||||
foreach (var (id, row) in data.Merge<ChallengeTargetExcel>(file, r => UInt(r, "ID"), required: false))
|
||||
{
|
||||
targets.TryAdd(id, row);
|
||||
}
|
||||
}
|
||||
|
||||
data.ChallengeTargets = targets;
|
||||
data.BattleTargets = data.Merge<BattleTargetExcel>("BattleTargetConfig.json", r => UInt(r, "ID"), required: false);
|
||||
|
||||
data.ChallengePeaks = data.Merge<ChallengePeakExcel>("ChallengePeakConfig.json", r => UInt(r, "ID"), required: false);
|
||||
data.ChallengePeakBosses = data.Merge<ChallengePeakBossExcel>("ChallengePeakBossConfig.json", r => UInt(r, "ID"), required: false);
|
||||
data.MapEntrances = data.Merge<MapEntranceExcel>("MapEntrance.json", r => UInt(r, "ID"), required: false);
|
||||
data.ChallengePeakGroups = data.Merge<ChallengePeakGroupExcel>("ChallengePeakGroupConfig.json", r => UInt(r, "ID"), required: false);
|
||||
data.MazePlanes = data.Merge<MazePlaneExcel>("MazePlane.json", r => UInt(r, "PlaneID"), required: false);
|
||||
data.Scenes = data.Merge<SceneResourceExcel>("res.json", r => UInt(r, "entryID"), required: false);
|
||||
data.Anchors = data.Merge<AnchorExcel>("Anchor.json", r => UInt(r, "entryID"), required: false);
|
||||
data.FloorSavedValues = data.Merge<FloorSavedValuesExcel>("FloorSavedValuesConfig.json", r => UInt(r, "FloorID"), required: false);
|
||||
data.MainMissions = data.Merge<MainMissionExcel>("MainMission.json", r => UInt(r, "MainMissionID"), required: false);
|
||||
data.TutorialIds = [.. data.Merge<TutorialExcel>("TutorialData.json", r => UInt(r, "TutorialID"), required: false).Keys];
|
||||
data.TutorialGuideIds = [.. data.Merge<TutorialGuideGroupExcel>("TutorialGuideGroup.json", r => UInt(r, "GroupID"), required: false).Keys];
|
||||
data.GachaCeilingAvatars = [.. data
|
||||
.Merge<GachaCeilingExcel>("GachaCeiling.json", r => Str(r, "GachaType") == "Normal" ? 0u : null, required: false)
|
||||
.Values.SelectMany(r => r.CeilingItemList)];
|
||||
data.KnownRelicIds = data.Merge<ItemConfigExcel>("ItemConfigRelic.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet();
|
||||
data.KnownLightconeIds = data.Merge<ItemConfigExcel>("ItemConfigEquipment.json", r => UInt(r, "ID"), required: false).Keys.ToHashSet();
|
||||
|
||||
data.Cocoons = data.Merge<CocoonExcel>("CocoonConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
|
||||
data.FarmElements = data.Merge<FarmElementExcel>("FarmElementConfig.json", r => (UInt(r, "ID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
|
||||
data.PlaneEvents = data.Merge<PlaneEventExcel>("PlaneEvent.json", r => (UInt(r, "EventID") * 10) + (UInt(r, "WorldLevel") ?? 0), required: false);
|
||||
data.BattleColleges = data.Merge<BattleCollegeExcel>("BattleCollegeConfig.json", r => UInt(r, "ID"), required: false);
|
||||
data.SpecialAvatars = data.Merge<SpecialAvatarExcel>("SpecialAvatar.json", r => UInt(r, "SpecialAvatarID"), required: false);
|
||||
data.NpcMonsters = data.Merge<NpcMonsterExcel>("NPCMonsterData.json", r => UInt(r, "ID"), required: false);
|
||||
data.Props = data.Merge<MazePropExcel>("MazeProp.json", r => UInt(r, "ID"), required: false);
|
||||
data.HeadIcons = data.Merge<HeadIconExcel>("AvatarPlayerIcon.json", r => UInt(r, "ID"), required: false);
|
||||
data.StageInvasions = data.Merge<StageInvasionExcel>("StageInvasionConfig.json", r => UInt(r, "StageID"), required: false);
|
||||
data.DestructiblePropIds = data.Props.Values.Where(p => p.IsDestructible).Select(p => p.ID).ToHashSet();
|
||||
|
||||
var multiPath = data.Merge<MultiPathAvatarExcel>("MultiplePathAvatarConfig.json", r => UInt(r, "AvatarID"), required: false);
|
||||
data.BaseAvatarIds = multiPath.ToDictionary(kv => kv.Key, kv => kv.Value.BaseAvatarID == 0 ? kv.Key : kv.Value.BaseAvatarID);
|
||||
data.MultiPathAvatarIds = multiPath.Keys.ToHashSet();
|
||||
|
||||
data.GlobalMazeBuffs = [.. data.MazeBuffs.Values
|
||||
.Where(b => b.IsGlobal)
|
||||
.Select(b => (b.OwnerAvatarId, b.ID))
|
||||
.OrderBy(b => b.Item1)];
|
||||
|
||||
log($"game data loaded in {stopwatch.ElapsedMilliseconds} ms from {roots.Length} source(s): " +
|
||||
$"{data.Avatars.Count} avatars, {data.SkillTrees.Sum(t => t.Value.Count)} skill tree points, " +
|
||||
$"{data.Stages.Count} stages, {data.Monsters.Count} monsters, {data.Equipment.Count} lightcones, " +
|
||||
$"{data.Challenges.Count} challenge floors, {data.ChallengePeaks.Count(p => p.Value.IsPlayable)}/" +
|
||||
$"{data.ChallengePeaks.Count} playable AA stages, {data.Scenes.Count} scenes, " +
|
||||
$"{data.Cocoons.Count} calyx rows, {data.FarmElements.Count} farm rows, {data.PlaneEvents.Count} plane events, " +
|
||||
$"{data.MainMissions.Count} main missions, " +
|
||||
$"{data.TutorialIds.Count} tutorials / {data.TutorialGuideIds.Count} guides");
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public AvatarExcel? GetAvatar(uint avatarId) => Avatars.GetValueOrDefault(avatarId);
|
||||
|
||||
public StageExcel? GetStage(uint stageId) => Stages.GetValueOrDefault(stageId);
|
||||
|
||||
public ChallengeMazeExcel? GetChallenge(uint challengeId) => Challenges.GetValueOrDefault(challengeId);
|
||||
|
||||
public uint BaseAvatarId(uint avatarId) => BaseAvatarIds.TryGetValue(avatarId, out var baseId)
|
||||
? baseId
|
||||
: avatarId switch
|
||||
{
|
||||
>= 8001 and <= 8010 => 8001,
|
||||
1224 => 1001,
|
||||
_ => avatarId,
|
||||
};
|
||||
|
||||
// the row for this world level, else the closest lower one, else any
|
||||
public CocoonExcel? FindCocoon(uint cocoonId, uint worldLevel)
|
||||
{
|
||||
for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--)
|
||||
{
|
||||
if (Cocoons.TryGetValue((cocoonId * 10) + (uint)level, out var row) && row.Stages.Count > 0)
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
return Cocoons.Values.FirstOrDefault(c => c.ID == cocoonId && c.Stages.Count > 0);
|
||||
}
|
||||
|
||||
public FarmElementExcel? FindFarmElement(uint farmId, uint worldLevel)
|
||||
{
|
||||
for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--)
|
||||
{
|
||||
if (FarmElements.TryGetValue((farmId * 10) + (uint)level, out var row) && row.StageID != 0)
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
return FarmElements.Values.FirstOrDefault(f => f.ID == farmId && f.StageID != 0);
|
||||
}
|
||||
|
||||
// an overworld monster's event -> the stage it fights at this world level
|
||||
public uint ResolveStageForEvent(uint eventId, uint worldLevel)
|
||||
{
|
||||
if (eventId == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var level = (int)Math.Min(worldLevel, 9); level >= 0; level--)
|
||||
{
|
||||
if (PlaneEvents.TryGetValue((eventId * 10) + (uint)level, out var planeEvent) && planeEvent.StageID != 0)
|
||||
{
|
||||
return planeEvent.StageID;
|
||||
}
|
||||
}
|
||||
|
||||
if (Stages.ContainsKey((eventId * 10) + worldLevel))
|
||||
{
|
||||
return (eventId * 10) + worldLevel;
|
||||
}
|
||||
|
||||
if (Stages.ContainsKey(eventId))
|
||||
{
|
||||
return eventId;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public bool IsFodderMonster(uint npcMonsterId) =>
|
||||
NpcMonsters.TryGetValue(npcMonsterId, out var monster) && monster.IsFodder;
|
||||
|
||||
// monster placement lives per plane, matched by the challenge's maze group
|
||||
public ScenePlacement? FindMonsterPlacement(uint planeId, uint groupId)
|
||||
{
|
||||
foreach (var scene in Scenes.Values.Where(s => s.PlaneID == planeId))
|
||||
{
|
||||
foreach (var monster in scene.Monsters.Where(m => m.GroupId == groupId))
|
||||
{
|
||||
return monster;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public SceneResourceExcel? GetScene(uint entryId) => Scenes.GetValueOrDefault(entryId);
|
||||
|
||||
// teleport ids are shared by every entry on a plane, so a point picked on the map is
|
||||
// often not listed under the entry the client asks to enter
|
||||
public SceneTeleport? FindTeleport(uint entryId, uint teleportId)
|
||||
{
|
||||
if (teleportId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scene = GetScene(entryId);
|
||||
|
||||
if (scene?.Teleports.FirstOrDefault(t => t.TeleportId == teleportId) is { } exact)
|
||||
{
|
||||
return exact;
|
||||
}
|
||||
|
||||
var planeId = scene?.PlaneID ?? ResolveEntrance(entryId)?.Entrance.PlaneID ?? 0;
|
||||
|
||||
return Scenes.Values
|
||||
.Where(s => s.PlaneID == planeId)
|
||||
.SelectMany(s => s.Teleports)
|
||||
.FirstOrDefault(t => t.TeleportId == teleportId);
|
||||
}
|
||||
|
||||
public IEnumerable<uint> TeleportIdsForEntry(uint entryId) =>
|
||||
TeleportIdsForPlane(GetScene(entryId)?.PlaneID ?? ResolveEntrance(entryId)?.Entrance.PlaneID ?? 0);
|
||||
|
||||
// floor ids are the plane id with a three digit suffix
|
||||
public IEnumerable<uint> TeleportIdsForFloor(uint floorId) => TeleportIdsForPlane(floorId / 1000);
|
||||
|
||||
private IEnumerable<uint> TeleportIdsForPlane(uint planeId) =>
|
||||
Scenes.Values
|
||||
.Where(s => s.PlaneID == planeId)
|
||||
.SelectMany(s => s.Teleports)
|
||||
.Select(t => t.TeleportId)
|
||||
.Distinct();
|
||||
|
||||
public AnchorPoint? FindAnchor(uint entryId) =>
|
||||
Anchors.TryGetValue(entryId, out var anchor) && anchor.Anchor.Count > 0 ? anchor.Anchor[0] : null;
|
||||
|
||||
// an entrance points at a floor; the plane it belongs to carries the world id
|
||||
public (MapEntranceExcel Entrance, MazePlaneExcel? Plane)? ResolveEntrance(uint entranceId)
|
||||
{
|
||||
if (!MapEntrances.TryGetValue(entranceId, out var entrance))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plane = MazePlanes.GetValueOrDefault(entrance.PlaneID)
|
||||
?? MazePlanes.Values.FirstOrDefault(p => p.FloorIDList.Contains(entrance.FloorID));
|
||||
|
||||
return (entrance, plane);
|
||||
}
|
||||
|
||||
// AvatarPathData.point_id is the anchor index ("Point01" -> 1), not the raw point id
|
||||
public IEnumerable<(uint AnchorIndex, uint Level)> MaxedPathSkillTree(uint avatarId)
|
||||
{
|
||||
if (!SkillTrees.TryGetValue(avatarId, out var points))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var group in points.Where(p => p.AnchorIndex > 0).GroupBy(p => p.AnchorIndex))
|
||||
{
|
||||
var best = group.MaxBy(p => p.Level)!;
|
||||
yield return (group.Key, best.MaxLevel > 0 ? best.MaxLevel : best.Level);
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<(uint PointId, uint Level)> MaxedSkillTree(uint avatarId)
|
||||
{
|
||||
if (!SkillTrees.TryGetValue(avatarId, out var points))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var group in points.GroupBy(p => p.PointID))
|
||||
{
|
||||
var best = group.MaxBy(p => p.Level)!;
|
||||
yield return (best.PointID, best.MaxLevel > 0 ? best.MaxLevel : best.Level);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CapySR.Data.SrTools;
|
||||
|
||||
// freesr-data.json, as written by srtools.neonteam.dev
|
||||
public sealed class SrToolsData
|
||||
{
|
||||
public Dictionary<uint, SrAvatar> Avatars { get; set; } = [];
|
||||
|
||||
public List<SrRelic> Relics { get; set; } = [];
|
||||
|
||||
public List<SrLightcone> Lightcones { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("battle_config")]
|
||||
public SrBattleConfig BattleConfig { get; set; } = new();
|
||||
|
||||
public static SrToolsData Empty { get; } = new();
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
public const string FileName = "freesr-data.json";
|
||||
|
||||
public static SrToolsData Load(string path) =>
|
||||
File.Exists(path)
|
||||
? JsonSerializer.Deserialize<SrToolsData>(File.ReadAllText(path), Options) ?? new SrToolsData()
|
||||
: new SrToolsData();
|
||||
|
||||
// robinsr and srtools users drop it at the repo root; we also accept data/
|
||||
public static string ResolvePath(string root)
|
||||
{
|
||||
var atRoot = Path.Combine(root, FileName);
|
||||
var inData = Path.Combine(root, "data", FileName);
|
||||
|
||||
return File.Exists(atRoot) || !File.Exists(inData) ? atRoot : inData;
|
||||
}
|
||||
|
||||
public SrLightcone? LightconeFor(uint avatarId) => Lightcones.FirstOrDefault(l => l.EquipAvatar == avatarId);
|
||||
|
||||
public IEnumerable<SrRelic> RelicsFor(uint avatarId) => Relics.Where(r => r.EquipAvatar == avatarId);
|
||||
}
|
||||
|
||||
public sealed class SrAvatar
|
||||
{
|
||||
[JsonPropertyName("avatar_id")]
|
||||
public uint AvatarId { get; set; }
|
||||
|
||||
public uint Level { get; set; } = 80;
|
||||
|
||||
public uint Promotion { get; set; } = 6;
|
||||
|
||||
public SrAvatarData Data { get; set; } = new();
|
||||
|
||||
// the buff ids the avatar enters battle with
|
||||
[JsonPropertyName("techniques")]
|
||||
public List<uint> Techniques { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("sp_value")]
|
||||
public uint? SpValue { get; set; }
|
||||
|
||||
[JsonPropertyName("sp_max")]
|
||||
public uint? SpMax { get; set; }
|
||||
|
||||
[JsonPropertyName("enhanced_id")]
|
||||
public uint? EnhancedId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SrAvatarData
|
||||
{
|
||||
public uint Rank { get; set; }
|
||||
|
||||
// point id -> level
|
||||
public Dictionary<uint, uint> Skills { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("skills_by_anchor_type")]
|
||||
public Dictionary<uint, uint> SkillsByAnchorType { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SrRelic
|
||||
{
|
||||
public uint Level { get; set; }
|
||||
|
||||
[JsonPropertyName("relic_id")]
|
||||
public uint RelicId { get; set; }
|
||||
|
||||
[JsonPropertyName("main_affix_id")]
|
||||
public uint MainAffixId { get; set; }
|
||||
|
||||
[JsonPropertyName("sub_affixes")]
|
||||
public List<SrSubAffix> SubAffixes { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("internal_uid")]
|
||||
public uint InternalUid { get; set; }
|
||||
|
||||
[JsonPropertyName("equip_avatar")]
|
||||
public uint EquipAvatar { get; set; }
|
||||
|
||||
public uint Slot => RelicId % 10;
|
||||
|
||||
// srtools numbers from 0, but 0 means "no item" on the wire
|
||||
public uint UniqueId => InternalUid + 1;
|
||||
}
|
||||
|
||||
public sealed class SrSubAffix
|
||||
{
|
||||
[JsonPropertyName("sub_affix_id")]
|
||||
public uint SubAffixId { get; set; }
|
||||
|
||||
public uint Count { get; set; }
|
||||
|
||||
public uint Step { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SrLightcone
|
||||
{
|
||||
public uint Level { get; set; }
|
||||
|
||||
[JsonPropertyName("item_id")]
|
||||
public uint ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("equip_avatar")]
|
||||
public uint EquipAvatar { get; set; }
|
||||
|
||||
public uint Rank { get; set; }
|
||||
|
||||
public uint Promotion { get; set; }
|
||||
|
||||
[JsonPropertyName("internal_uid")]
|
||||
public uint InternalUid { get; set; }
|
||||
|
||||
// lightcones live in a separate unique-id range from relics
|
||||
public uint UniqueId => 3001 + InternalUid;
|
||||
}
|
||||
|
||||
public sealed class SrBattleConfig
|
||||
{
|
||||
[JsonPropertyName("battle_type")]
|
||||
public string BattleType { get; set; } = "Default";
|
||||
|
||||
[JsonPropertyName("stage_id")]
|
||||
public uint StageId { get; set; }
|
||||
|
||||
[JsonPropertyName("cycle_count")]
|
||||
public uint CycleCount { get; set; } = 30;
|
||||
|
||||
public List<List<SrMonster>> Monsters { get; set; } = [];
|
||||
|
||||
public List<SrBlessing> Blessings { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("path_resonance_id")]
|
||||
public uint PathResonanceId { get; set; }
|
||||
|
||||
[JsonPropertyName("custom_stats")]
|
||||
public List<SrSubAffix> CustomStats { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("custom_battle_lineup")]
|
||||
public Dictionary<uint, uint>? CustomLineup { get; set; }
|
||||
|
||||
public BattleKind Kind => BattleType.ToUpperInvariant() switch
|
||||
{
|
||||
"MOC" or "1" => BattleKind.Moc,
|
||||
"PF" or "2" => BattleKind.PureFiction,
|
||||
"SU" or "3" => BattleKind.SimulatedUniverse,
|
||||
"AS" or "4" => BattleKind.ApocalypticShadow,
|
||||
"AA" or "5" => BattleKind.AnomalyArbitration,
|
||||
_ => BattleKind.Default,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class SrMonster
|
||||
{
|
||||
public uint Level { get; set; } = 95;
|
||||
|
||||
[JsonPropertyName("monster_id")]
|
||||
public uint MonsterId { get; set; }
|
||||
|
||||
[JsonPropertyName("max_hp")]
|
||||
public uint MaxHp { get; set; }
|
||||
|
||||
// srtools can ask for the same monster several times in one wave
|
||||
public uint Amount { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class SrBlessing
|
||||
{
|
||||
public uint Id { get; set; }
|
||||
|
||||
public uint Level { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("dynamic_key")]
|
||||
public SrDynamicValue? DynamicKey { get; set; }
|
||||
|
||||
[JsonPropertyName("dynamic_values")]
|
||||
public List<SrDynamicValue> DynamicValues { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SrDynamicValue
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public float Value { get; set; }
|
||||
}
|
||||
|
||||
public enum BattleKind
|
||||
{
|
||||
Default,
|
||||
Moc,
|
||||
PureFiction,
|
||||
SimulatedUniverse,
|
||||
ApocalypticShadow,
|
||||
AnomalyArbitration,
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Battle;
|
||||
|
||||
public sealed class BattleRequest
|
||||
{
|
||||
public uint StageId { get; set; }
|
||||
|
||||
// further stages whose waves follow the first one's: several monsters pulled at once
|
||||
public List<uint> ExtraStageIds { get; set; } = [];
|
||||
|
||||
public uint BattleId { get; set; } = 1;
|
||||
|
||||
public IReadOnlyList<uint> Lineup { get; set; } = [];
|
||||
|
||||
// index (in Lineup) of the avatar that opened the fight
|
||||
public uint LeaderSlot { get; set; }
|
||||
|
||||
// 1 = normal attack, 2 = technique; drives the toughness-break buff on entry
|
||||
public uint SkillIndex { get; set; } = 1;
|
||||
|
||||
// a monster attacked first
|
||||
public bool Ambush { get; set; }
|
||||
|
||||
public BattleKind Kind { get; set; } = BattleKind.Default;
|
||||
|
||||
// 0 = no cycle limit, the overworld default; challenges set their own
|
||||
public uint CycleCount { get; set; }
|
||||
|
||||
public uint WorldLevel { get; set; } = 6;
|
||||
|
||||
// set when the player started the fight by hitting something in the overworld
|
||||
public bool PlayerInitiated { get; set; } = true;
|
||||
|
||||
public uint MonsterLevel { get; set; }
|
||||
|
||||
public List<List<SrMonster>>? MonsterWaves { get; set; }
|
||||
|
||||
public List<SrBlessing> Blessings { get; set; } = [];
|
||||
|
||||
// simulated universe path resonance
|
||||
public uint PathResonanceId { get; set; }
|
||||
|
||||
public List<SrSubAffix> CustomStats { get; set; } = [];
|
||||
|
||||
// stage-lent characters replace the lineup when present
|
||||
public List<SpecialAvatarExcel> TrialAvatars { get; set; } = [];
|
||||
|
||||
public Gender Gender { get; set; } = Gender.Woman;
|
||||
|
||||
// PF: score carried over from the first half
|
||||
public uint ChallengeScore { get; set; }
|
||||
|
||||
public List<uint> ScoreTargets { get; set; } = [];
|
||||
|
||||
public List<(uint Id, uint Param)> PeakTargets { get; set; } = [];
|
||||
|
||||
public Func<uint, AvatarState>? StateOf { get; set; }
|
||||
|
||||
public Func<uint, bool>? IsEnhanced { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BattleBuilder(GameData data)
|
||||
{
|
||||
private const uint AllWaves = 0xFFFFFFFF;
|
||||
private const uint FirstWave = 1;
|
||||
private const uint NoOwner = 0xFFFFFFFF;
|
||||
private const uint AmbushBuff = 1000102;
|
||||
|
||||
public SceneBattleInfo Build(BattleRequest request, SrToolsData player)
|
||||
{
|
||||
var stage = data.GetStage(request.StageId);
|
||||
var waves = ResolveWaves(request, stage);
|
||||
|
||||
var battle = new SceneBattleInfo
|
||||
{
|
||||
BattleId = request.BattleId,
|
||||
StageId = request.StageId,
|
||||
LogicRandomSeed = (uint)Random.Shared.Next(),
|
||||
RoundsLimit = request.CycleCount,
|
||||
MonsterWaveLength = (uint)waves.Count,
|
||||
WorldLevel = request.WorldLevel,
|
||||
};
|
||||
|
||||
var hasTrial = request.TrialAvatars.Count > 0;
|
||||
|
||||
if (hasTrial)
|
||||
{
|
||||
BuildTrialAvatars(battle, request);
|
||||
}
|
||||
else
|
||||
{
|
||||
BuildAvatars(battle, request, player);
|
||||
}
|
||||
|
||||
AddEntryBuff(battle, request, player, hasTrial);
|
||||
AddBlessings(battle, request);
|
||||
|
||||
if (!hasTrial)
|
||||
{
|
||||
AddGlobalBuffs(battle, player);
|
||||
ApplyCustomStats(battle, request);
|
||||
}
|
||||
|
||||
AddPathResonance(battle, request);
|
||||
AddStageInvasion(battle, request);
|
||||
AddMonsterWaves(battle, request, waves, stage);
|
||||
BattleTargets.Apply(battle, request);
|
||||
|
||||
return battle;
|
||||
}
|
||||
|
||||
private void BuildAvatars(SceneBattleInfo battle, BattleRequest request, SrToolsData player)
|
||||
{
|
||||
var index = 0u;
|
||||
|
||||
foreach (var avatarId in request.Lineup.Where(id => id != 0))
|
||||
{
|
||||
var slot = index++;
|
||||
battle.BattleAvatarList.Add(BuildAvatar(avatarId, slot, request, player));
|
||||
|
||||
player.Avatars.TryGetValue(avatarId, out var configured);
|
||||
|
||||
// srtools writes the exact technique buff ids it wants; otherwise derive them
|
||||
if (configured is { Techniques.Count: > 0 })
|
||||
{
|
||||
foreach (var id in configured.Techniques)
|
||||
{
|
||||
battle.BuffList.Add(TechniqueBuff(id, slot, 2f));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var buff in TechniqueBuffs.For(data, avatarId))
|
||||
{
|
||||
battle.BuffList.Add(
|
||||
TechniqueBuff(buff.Id, buff.OwnedByLeader ? request.LeaderSlot : slot, buff.SkillIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trial characters come fully specified by SpecialAvatar; the roster is not consulted
|
||||
private void BuildTrialAvatars(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
var index = 0u;
|
||||
|
||||
foreach (var special in request.TrialAvatars)
|
||||
{
|
||||
if (!MatchesGender(special.AvatarID, request.Gender) || !data.Avatars.ContainsKey(special.AvatarID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var avatar = new BattleAvatar
|
||||
{
|
||||
Index = index++,
|
||||
Id = special.AvatarID,
|
||||
AvatarType = AvatarType.AvatarTrialType,
|
||||
Level = special.Level == 0 ? 80 : special.Level,
|
||||
Promotion = special.Promotion,
|
||||
Rank = 0,
|
||||
Hp = Player.FullHp,
|
||||
WorldLevel = request.WorldLevel,
|
||||
SpBar = new SpBarInfo { CurSp = 5_000, MaxSp = 10_000 },
|
||||
};
|
||||
|
||||
foreach (var (pointId, level) in data.MaxedSkillTree(special.AvatarID))
|
||||
{
|
||||
avatar.SkilltreeList.Add(new AvatarSkillTree { PointId = pointId, Level = level });
|
||||
}
|
||||
|
||||
if (special.EquipmentID != 0)
|
||||
{
|
||||
avatar.EquipmentList.Add(new BattleEquipment
|
||||
{
|
||||
Id = special.EquipmentID,
|
||||
Level = special.EquipmentLevel,
|
||||
Promotion = special.EquipmentPromotion,
|
||||
Rank = Math.Max(1, special.EquipmentRank),
|
||||
});
|
||||
}
|
||||
|
||||
battle.BattleAvatarList.Add(avatar);
|
||||
|
||||
foreach (var buff in TechniqueBuffs.For(data, special.AvatarID))
|
||||
{
|
||||
battle.BuffList.Add(TechniqueBuff(buff.Id, buff.OwnedByLeader ? 0 : avatar.Index, buff.SkillIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trailblazer trial rows come in pairs; keep the one matching the player's gender
|
||||
private static bool MatchesGender(uint avatarId, Gender gender) =>
|
||||
avatarId is < 8001 or > 8010 || (avatarId % 2 == 1) == (gender == Gender.Man);
|
||||
|
||||
private static BattleBuff TechniqueBuff(uint id, uint ownerIndex, float skillIndex) => new()
|
||||
{
|
||||
Id = id,
|
||||
Level = 1,
|
||||
OwnerIndex = ownerIndex,
|
||||
WaveFlag = AllWaves,
|
||||
TargetIndexList = { 0u },
|
||||
DynamicValues = { ["SkillIndex"] = skillIndex },
|
||||
};
|
||||
|
||||
private BattleAvatar BuildAvatar(uint avatarId, uint index, BattleRequest request, SrToolsData player)
|
||||
{
|
||||
player.Avatars.TryGetValue(avatarId, out var configured);
|
||||
var state = request.StateOf?.Invoke(avatarId);
|
||||
|
||||
var avatar = new BattleAvatar
|
||||
{
|
||||
Index = index,
|
||||
Id = avatarId,
|
||||
AvatarType = AvatarType.AvatarFormalType,
|
||||
Level = configured?.Level ?? 80,
|
||||
Promotion = configured?.Promotion ?? 6,
|
||||
Rank = configured?.Data.Rank ?? 6,
|
||||
Hp = state?.Hp ?? Player.FullHp,
|
||||
WorldLevel = request.WorldLevel,
|
||||
SpBar = state?.SpBar ?? new SpBarInfo
|
||||
{
|
||||
CurSp = configured?.SpValue ?? 10_000,
|
||||
MaxSp = configured?.SpMax ?? 10_000,
|
||||
},
|
||||
};
|
||||
|
||||
var enhanced = request.IsEnhanced is null
|
||||
? configured?.EnhancedId is > 0
|
||||
: request.IsEnhanced(avatarId);
|
||||
|
||||
// only avatars that actually have an enhanced kit can enter with one
|
||||
if (enhanced && (configured?.EnhancedId is > 0 || data.EnhancedAvatars.ContainsKey(avatarId)))
|
||||
{
|
||||
avatar.EnhancedId = configured?.EnhancedId is > 0
|
||||
? configured.EnhancedId.Value
|
||||
: Roster.EnhancedId(data, avatarId);
|
||||
}
|
||||
|
||||
// srtools carries the player's actual trace levels; otherwise give a fully built kit
|
||||
if (configured is { Data.Skills.Count: > 0 })
|
||||
{
|
||||
foreach (var (pointId, level) in configured.Data.Skills)
|
||||
{
|
||||
avatar.SkilltreeList.Add(new AvatarSkillTree { PointId = pointId, Level = level });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (pointId, level) in data.MaxedSkillTree(avatarId))
|
||||
{
|
||||
avatar.SkilltreeList.Add(new AvatarSkillTree { PointId = pointId, Level = level });
|
||||
}
|
||||
}
|
||||
|
||||
if (player.LightconeFor(avatarId) is { } lightcone)
|
||||
{
|
||||
avatar.EquipmentList.Add(new BattleEquipment
|
||||
{
|
||||
Id = lightcone.ItemId,
|
||||
Level = lightcone.Level,
|
||||
Promotion = lightcone.Promotion,
|
||||
Rank = lightcone.Rank,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var relic in player.RelicsFor(avatarId))
|
||||
{
|
||||
var battleRelic = new BattleRelic
|
||||
{
|
||||
Id = relic.RelicId,
|
||||
Level = relic.Level,
|
||||
MainAffixId = relic.MainAffixId,
|
||||
UniqueId = relic.UniqueId,
|
||||
};
|
||||
|
||||
foreach (var affix in relic.SubAffixes)
|
||||
{
|
||||
battleRelic.SubAffixList.Add(new RelicAffix
|
||||
{
|
||||
AffixId = affix.SubAffixId,
|
||||
Cnt = affix.Count,
|
||||
Step = affix.Step,
|
||||
});
|
||||
}
|
||||
|
||||
avatar.RelicList.Add(battleRelic);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
// who struck first decides how the fight opens: the attacker's element breaks toughness,
|
||||
// an ambushing monster gets the drop on the party instead
|
||||
private void AddEntryBuff(SceneBattleInfo battle, BattleRequest request, SrToolsData player, bool trial)
|
||||
{
|
||||
if (request.Ambush)
|
||||
{
|
||||
battle.BuffList.Add(new BattleBuff
|
||||
{
|
||||
Id = AmbushBuff,
|
||||
Level = 1,
|
||||
OwnerIndex = NoOwner,
|
||||
WaveFlag = FirstWave,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.PlayerInitiated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var avatars = trial
|
||||
? battle.BattleAvatarList.Select(a => a.Id).ToList()
|
||||
: request.Lineup.Where(id => id != 0).ToList();
|
||||
|
||||
var attacker = avatars.ElementAtOrDefault((int)request.LeaderSlot);
|
||||
if (attacker == 0 || TechniqueBuffs.IgnoresToughnessOnEntry(attacker))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var element = data.GetAvatar(attacker)?.DamageType ?? string.Empty;
|
||||
var buffId = TechniqueBuffs.AttackerBuff(element);
|
||||
|
||||
if (buffId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
battle.BuffList.Add(new BattleBuff
|
||||
{
|
||||
Id = buffId,
|
||||
Level = 1,
|
||||
OwnerIndex = request.LeaderSlot,
|
||||
WaveFlag = AllWaves,
|
||||
TargetIndexList = { 0u },
|
||||
DynamicValues = { ["SkillIndex"] = request.SkillIndex },
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddBlessings(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
foreach (var blessing in request.Blessings)
|
||||
{
|
||||
var buff = new BattleBuff
|
||||
{
|
||||
Id = blessing.Id,
|
||||
Level = blessing.Level == 0 ? 1 : blessing.Level,
|
||||
OwnerIndex = NoOwner,
|
||||
WaveFlag = AllWaves,
|
||||
TargetIndexList = { 0u },
|
||||
};
|
||||
|
||||
if (blessing.DynamicKey is { } key && key.Key.Length > 0)
|
||||
{
|
||||
buff.DynamicValues[key.Key] = key.Value;
|
||||
}
|
||||
|
||||
foreach (var value in blessing.DynamicValues)
|
||||
{
|
||||
buff.DynamicValues.TryAdd(value.Key, value.Value);
|
||||
}
|
||||
|
||||
buff.DynamicValues.TryAdd("SkillIndex", 0f);
|
||||
battle.BuffList.Add(buff);
|
||||
}
|
||||
}
|
||||
|
||||
// ADV_GlobalSkill_Maze buffs apply when the account owns the avatar, lineup or not
|
||||
private void AddGlobalBuffs(SceneBattleInfo battle, SrToolsData player)
|
||||
{
|
||||
foreach (var (avatarId, buffId) in data.GlobalMazeBuffs)
|
||||
{
|
||||
if (!player.Avatars.ContainsKey(avatarId) || battle.BuffList.Any(b => b.Id == buffId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
battle.BuffList.Add(new BattleBuff
|
||||
{
|
||||
Id = buffId,
|
||||
Level = 1,
|
||||
OwnerIndex = NoOwner,
|
||||
WaveFlag = AllWaves,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// srtools' custom stats ride on the first relic of every avatar
|
||||
private static void ApplyCustomStats(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
if (request.CustomStats.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var avatar in battle.BattleAvatarList)
|
||||
{
|
||||
if (avatar.RelicList.Count == 0)
|
||||
{
|
||||
avatar.RelicList.Add(new BattleRelic { Id = 61011, MainAffixId = 1, Level = 1 });
|
||||
}
|
||||
|
||||
var relic = avatar.RelicList[0];
|
||||
|
||||
foreach (var stat in request.CustomStats)
|
||||
{
|
||||
var existing = relic.SubAffixList.FirstOrDefault(a => a.AffixId == stat.SubAffixId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Cnt = stat.Count;
|
||||
existing.Step = stat.Step;
|
||||
}
|
||||
else
|
||||
{
|
||||
relic.SubAffixList.Add(new RelicAffix { AffixId = stat.SubAffixId, Cnt = stat.Count, Step = stat.Step });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the invading monster on some PF floors rides in as a stage buff
|
||||
private void AddStageInvasion(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
if (!data.StageInvasions.TryGetValue(request.StageId, out var invasion) || invasion.InvasionID == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
battle.BuffList.Add(new BattleBuff
|
||||
{
|
||||
Id = 3034000 + invasion.InvasionID,
|
||||
Level = 1,
|
||||
OwnerIndex = request.LeaderSlot,
|
||||
WaveFlag = AllWaves,
|
||||
TargetIndexList = { 0u },
|
||||
DynamicValues = { ["SkillIndex"] = 1f },
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddPathResonance(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
if (request.PathResonanceId == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
battle.BattleEvent.Add(new BattleEventBattleInfo
|
||||
{
|
||||
BattleEventId = request.PathResonanceId,
|
||||
Status = new BattleEventProperty
|
||||
{
|
||||
SpBar = new SpBarInfo { CurSp = 10_000, MaxSp = 10_000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private List<(uint StageId, List<SrMonster> Monsters, StageExcel? Stage)> ResolveWaves(BattleRequest request, StageExcel? stage)
|
||||
{
|
||||
var waves = new List<(uint, List<SrMonster>, StageExcel?)>();
|
||||
|
||||
if (request.MonsterWaves is { Count: > 0 })
|
||||
{
|
||||
foreach (var wave in request.MonsterWaves)
|
||||
{
|
||||
waves.Add((request.StageId, wave, stage));
|
||||
}
|
||||
|
||||
return waves;
|
||||
}
|
||||
|
||||
foreach (var (stageId, excel) in Stages(request, stage))
|
||||
{
|
||||
var level = request.MonsterLevel > 0 ? request.MonsterLevel : excel.Level;
|
||||
|
||||
foreach (var wave in excel.MonsterList)
|
||||
{
|
||||
waves.Add((stageId, [.. wave.Select(id => new SrMonster { MonsterId = id, Level = level })], excel));
|
||||
}
|
||||
}
|
||||
|
||||
return waves;
|
||||
}
|
||||
|
||||
private IEnumerable<(uint StageId, StageExcel Stage)> Stages(BattleRequest request, StageExcel? first)
|
||||
{
|
||||
if (first is not null)
|
||||
{
|
||||
yield return (request.StageId, first);
|
||||
}
|
||||
|
||||
foreach (var stageId in request.ExtraStageIds.Where(id => id != request.StageId).Distinct())
|
||||
{
|
||||
if (data.GetStage(stageId) is { } stage)
|
||||
{
|
||||
yield return (stageId, stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddMonsterWaves(
|
||||
SceneBattleInfo battle, BattleRequest request,
|
||||
List<(uint StageId, List<SrMonster> Monsters, StageExcel? Stage)> waves, StageExcel? firstStage)
|
||||
{
|
||||
for (var i = 0; i < waves.Count; i++)
|
||||
{
|
||||
var (stageId, wave, stage) = waves[i];
|
||||
stage ??= firstStage;
|
||||
|
||||
var level = request.MonsterLevel > 0
|
||||
? request.MonsterLevel
|
||||
: wave.Count > 0 ? wave.Max(m => m.Level) : stage?.Level ?? 95;
|
||||
|
||||
var sceneWave = new SceneMonsterWave
|
||||
{
|
||||
BattleWaveId = (uint)i + 1,
|
||||
BattleStageId = stageId,
|
||||
MonsterParam = new SceneMonsterWaveParam
|
||||
{
|
||||
Level = level,
|
||||
HardLevelGroup = stage?.HardLevelGroup ?? 1,
|
||||
EliteGroup = stage?.EliteGroup ?? 0,
|
||||
},
|
||||
};
|
||||
|
||||
foreach (var monster in wave)
|
||||
{
|
||||
for (var n = 0; n < Math.Max(1, (int)monster.Amount); n++)
|
||||
{
|
||||
sceneWave.MonsterList.Add(new SceneMonster
|
||||
{
|
||||
MonsterId = monster.MonsterId,
|
||||
MaxHp = monster.MaxHp,
|
||||
CurHp = monster.MaxHp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
battle.MonsterWaveList.Add(sceneWave);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Handlers;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.GameServer.Scene;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Battle;
|
||||
|
||||
public sealed class CastOutcome
|
||||
{
|
||||
public SceneBattleInfo? Battle { get; set; }
|
||||
|
||||
public List<HitMonsterBattleInfo> Hits { get; } = [];
|
||||
|
||||
// props smashed and fodder one-shot by a technique
|
||||
public List<uint> RemovedEntities { get; } = [];
|
||||
|
||||
// orbs that refund technique points or heal on the way through
|
||||
public int MpOrbs { get; set; }
|
||||
|
||||
public int HpOrbs { get; set; }
|
||||
}
|
||||
|
||||
// starts fights for a session and settles them when the client reports back
|
||||
public sealed class BattleController(PlayerSession session)
|
||||
{
|
||||
private const uint FallbackStageId = 201012311;
|
||||
|
||||
private uint _nextBattleId;
|
||||
|
||||
public BattleInstance? Current { get; private set; }
|
||||
|
||||
public BattleEndStatus LastEndStatus { get; private set; }
|
||||
|
||||
public bool InBattle => Current is not null;
|
||||
|
||||
private GameWorld World => session.World;
|
||||
|
||||
private Player Player => session.Player;
|
||||
|
||||
private SrToolsData SrTools => World.Player;
|
||||
|
||||
// the srtools battle_config is a fight of its own. it drives every non-challenge fight,
|
||||
// calyxes included, so an old MOC/PF/AS stage can be replayed from one.
|
||||
private bool UseSrTools(BattleOrigin origin)
|
||||
{
|
||||
if (origin == BattleOrigin.Challenge)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = SrTools.BattleConfig;
|
||||
var ready = config.StageId != 0 && (config.Monsters.Count > 0 || World.Data.GetStage(config.StageId) is not null);
|
||||
|
||||
return session.Config.GameServer.BattleSource.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"stage" => false,
|
||||
_ => ready,
|
||||
};
|
||||
}
|
||||
|
||||
public void Quit()
|
||||
{
|
||||
if (Current is not null)
|
||||
{
|
||||
LastEndStatus = BattleEndStatus.BattleEndQuit;
|
||||
}
|
||||
|
||||
Current = null;
|
||||
}
|
||||
|
||||
public CastOutcome HandleCast(SceneCastSkillCsReq request)
|
||||
{
|
||||
var outcome = new CastOutcome();
|
||||
var scene = session.Scene;
|
||||
|
||||
var targetIds = request.HitTargetEntityIdList
|
||||
.Concat(request.AssistMonsterEntityIdList)
|
||||
.Concat(request.AssistMonsterEntityInfo.SelectMany(i => i.EntityIdList))
|
||||
.Where(id => id != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var prop in scene.PropsAmong(targetIds))
|
||||
{
|
||||
if (World.Data.Props.GetValueOrDefault(prop.PropId) is not { IsDestructible: true } excel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
outcome.RemovedEntities.Add(prop.EntityId);
|
||||
outcome.MpOrbs += excel.IsMpRecover ? 1 : 0;
|
||||
outcome.HpOrbs += excel.IsHpRecover ? 1 : 0;
|
||||
}
|
||||
|
||||
var monsters = scene.MonstersAmong(targetIds).ToList();
|
||||
var caster = scene.Get(request.AttackedByEntityId) ?? scene.Get(request.CastEntityId);
|
||||
var ambush = caster?.Kind == SceneEntityKind.Monster;
|
||||
|
||||
if (ambush && monsters.All(m => m.EntityId != caster!.EntityId))
|
||||
{
|
||||
monsters.Add(caster!);
|
||||
}
|
||||
|
||||
if (monsters.Count == 0)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
|
||||
// the client retries a cast it never got an answer for
|
||||
if (Current is not null)
|
||||
{
|
||||
outcome.Battle = Current.Info;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
var technique = request.SkillIndex == 1;
|
||||
var lineup = ResolveLineup(session.Challenge.Active ? session.Challenge.Lineup : Player.Lineups.Current.AvatarIds);
|
||||
var casterBase = caster?.Kind == SceneEntityKind.Actor ? caster.AvatarId : 0;
|
||||
var casterIndex = IndexOfBase(lineup, casterBase);
|
||||
|
||||
if (casterIndex < 0)
|
||||
{
|
||||
casterIndex = session.Challenge.Active ? 0 : (int)Player.Lineups.Current.LeaderIndex;
|
||||
}
|
||||
|
||||
// some techniques wipe fodder on the spot instead of starting a fight; never inside a challenge
|
||||
if (technique && !session.Challenge.Active && casterIndex < lineup.Count && TechniqueBuffs.KillsFodder(lineup[casterIndex]))
|
||||
{
|
||||
var fodder = monsters.Where(m => World.Data.IsFodderMonster(m.MonsterId)).ToList();
|
||||
|
||||
foreach (var monster in fodder)
|
||||
{
|
||||
outcome.Hits.Add(new HitMonsterBattleInfo
|
||||
{
|
||||
TargetMonsterEntityId = monster.EntityId,
|
||||
MonsterBattleType = MonsterBattleType.DirectDieSkipBattle,
|
||||
});
|
||||
|
||||
outcome.RemovedEntities.Add(monster.EntityId);
|
||||
}
|
||||
|
||||
monsters = [.. monsters.Except(fodder)];
|
||||
|
||||
if (monsters.Count == 0)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var monster in monsters)
|
||||
{
|
||||
outcome.Hits.Add(new HitMonsterBattleInfo
|
||||
{
|
||||
TargetMonsterEntityId = monster.EntityId,
|
||||
MonsterBattleType = MonsterBattleType.TriggerBattle,
|
||||
});
|
||||
}
|
||||
|
||||
var skillIndex = technique ? 2u : 1u;
|
||||
|
||||
var instance = session.Challenge.Active
|
||||
? StartChallenge(monsters, (uint)casterIndex, skillIndex, ambush)
|
||||
: UseSrTools(BattleOrigin.Overworld)
|
||||
? StartSrTools(BattleOrigin.Overworld, monsters, (uint)casterIndex, skillIndex, ambush)
|
||||
: StartOverworld(monsters, (uint)casterIndex, skillIndex, ambush);
|
||||
|
||||
outcome.Battle = instance.Info;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public BattleInstance? StartCocoon(uint cocoonId, uint wave, uint worldLevel)
|
||||
{
|
||||
if (UseSrTools(BattleOrigin.Cocoon))
|
||||
{
|
||||
return StartSrTools(BattleOrigin.Cocoon, [], 0, 1, false, cocoonId: cocoonId, wave: wave);
|
||||
}
|
||||
|
||||
var level = worldLevel == 0 ? Player.WorldLevel : worldLevel;
|
||||
var cocoon = World.Data.FindCocoon(cocoonId, level);
|
||||
|
||||
if (cocoon is null)
|
||||
{
|
||||
session.Logger.LogWarning("no calyx config for {Cocoon} at world level {Level}", cocoonId, level);
|
||||
return null;
|
||||
}
|
||||
|
||||
// every run of the calyx rolls one of its stages
|
||||
var runs = Math.Clamp((int)wave, 1, 6);
|
||||
var stages = Enumerable.Range(0, runs)
|
||||
.Select(_ => cocoon.Stages[Random.Shared.Next(cocoon.Stages.Count)])
|
||||
.ToList();
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = stages[0],
|
||||
ExtraStageIds = stages.Skip(1).ToList(),
|
||||
Lineup = ResolveLineup(Player.Lineups.Current.AvatarIds),
|
||||
LeaderSlot = Player.Lineups.Current.LeaderIndex,
|
||||
PlayerInitiated = false,
|
||||
}, BattleOrigin.Cocoon, [], cocoonId: cocoonId, wave: (uint)runs);
|
||||
}
|
||||
|
||||
public BattleInstance? StartFarmElement(uint farmId, uint worldLevel)
|
||||
{
|
||||
if (UseSrTools(BattleOrigin.FarmElement))
|
||||
{
|
||||
return StartSrTools(BattleOrigin.FarmElement, [], 0, 1, false, farmId: farmId);
|
||||
}
|
||||
|
||||
var level = worldLevel == 0 ? Player.WorldLevel : worldLevel;
|
||||
var farm = World.Data.FindFarmElement(farmId, level);
|
||||
|
||||
if (farm is null)
|
||||
{
|
||||
session.Logger.LogWarning("no farm element config for {Farm} at world level {Level}", farmId, level);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = farm.StageID,
|
||||
Lineup = ResolveLineup(Player.Lineups.Current.AvatarIds),
|
||||
LeaderSlot = Player.Lineups.Current.LeaderIndex,
|
||||
PlayerInitiated = false,
|
||||
}, BattleOrigin.FarmElement, [], farmId: farmId);
|
||||
}
|
||||
|
||||
public BattleInstance? StartCollege(uint collegeId)
|
||||
{
|
||||
if (!World.Data.BattleColleges.TryGetValue(collegeId, out var college))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trial = college.TrialAvatarList
|
||||
.Select(id => World.Data.SpecialAvatars.GetValueOrDefault(id))
|
||||
.OfType<SpecialAvatarExcel>()
|
||||
.ToList();
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = college.StageID,
|
||||
Lineup = trial.Count > 0 ? [] : ResolveLineup(Player.Lineups.Current.AvatarIds),
|
||||
TrialAvatars = trial,
|
||||
PlayerInitiated = false,
|
||||
}, BattleOrigin.College, [], collegeId: collegeId);
|
||||
}
|
||||
|
||||
// a stage entered by event id: story fights, stagnant shadows once activated, ...
|
||||
public BattleInstance? StartEvent(uint eventId)
|
||||
{
|
||||
if (Current is not null)
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
if (session.Challenge.Active)
|
||||
{
|
||||
return StartChallenge([], 0, 1, false);
|
||||
}
|
||||
|
||||
var stageId = World.Data.ResolveStageForEvent(eventId, Player.WorldLevel);
|
||||
|
||||
if (stageId == 0)
|
||||
{
|
||||
if (UseSrTools(BattleOrigin.Stage) || UseSrTools(BattleOrigin.Overworld))
|
||||
{
|
||||
return StartSrTools(BattleOrigin.Stage, [], 0, 1, false, eventId: eventId);
|
||||
}
|
||||
|
||||
session.Logger.LogWarning("event {Event} resolves to no stage", eventId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var stage = World.Data.GetStage(stageId);
|
||||
var trial = TrialAvatarsFor(stage);
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = stageId,
|
||||
Lineup = trial.Count > 0 ? [] : ResolveLineup(Player.Lineups.Current.AvatarIds),
|
||||
TrialAvatars = trial,
|
||||
LeaderSlot = Player.Lineups.Current.LeaderIndex,
|
||||
PlayerInitiated = false,
|
||||
}, BattleOrigin.Stage, [], eventId: eventId);
|
||||
}
|
||||
|
||||
private BattleInstance StartOverworld(List<SceneEntity> monsters, uint casterIndex, uint skillIndex, bool ambush)
|
||||
{
|
||||
var stages = monsters
|
||||
.Select(m => World.Data.ResolveStageForEvent(m.EventId, Player.WorldLevel))
|
||||
.Where(id => id != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (stages.Count == 0)
|
||||
{
|
||||
if (UseSrTools(BattleOrigin.Stage))
|
||||
{
|
||||
return StartSrTools(BattleOrigin.Overworld, monsters, casterIndex, skillIndex, ambush);
|
||||
}
|
||||
|
||||
session.Logger.LogWarning(
|
||||
"no stage for monsters [{Monsters}]; using stage {Stage}",
|
||||
string.Join(", ", monsters.Select(m => $"{m.MonsterId}/{m.EventId}")), FallbackStageId);
|
||||
|
||||
stages.Add(FallbackStageId);
|
||||
}
|
||||
|
||||
var stage = World.Data.GetStage(stages[0]);
|
||||
var trial = TrialAvatarsFor(stage);
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = stages[0],
|
||||
ExtraStageIds = stages.Skip(1).ToList(),
|
||||
Lineup = trial.Count > 0 ? [] : ResolveLineup(Player.Lineups.Current.AvatarIds),
|
||||
TrialAvatars = trial,
|
||||
LeaderSlot = casterIndex,
|
||||
SkillIndex = skillIndex,
|
||||
Ambush = ambush,
|
||||
}, BattleOrigin.Overworld, monsters, eventId: monsters[0].EventId);
|
||||
}
|
||||
|
||||
private BattleInstance StartSrTools(
|
||||
BattleOrigin origin, List<SceneEntity> monsters, uint casterIndex, uint skillIndex, bool ambush,
|
||||
uint cocoonId = 0, uint wave = 0, uint farmId = 0, uint eventId = 0)
|
||||
{
|
||||
var config = SrTools.BattleConfig;
|
||||
|
||||
// srtools' explicit lineup wins over the in-game team
|
||||
var lineup = config.CustomLineup is { Count: > 0 } custom
|
||||
? custom.OrderBy(kv => kv.Key).Select(kv => kv.Value).Where(id => id != 0).ToList()
|
||||
: ResolveLineup(Player.Lineups.Current.AvatarIds);
|
||||
|
||||
if (lineup.Count == 0)
|
||||
{
|
||||
lineup = [.. SrTools.Avatars.Keys.Where(Roster.IsPlayable).OrderBy(id => id).Take(4)];
|
||||
}
|
||||
|
||||
return Start(new BattleRequest
|
||||
{
|
||||
StageId = config.StageId,
|
||||
Lineup = lineup,
|
||||
LeaderSlot = Math.Min(casterIndex, (uint)Math.Max(0, lineup.Count - 1)),
|
||||
SkillIndex = skillIndex,
|
||||
Ambush = ambush,
|
||||
PlayerInitiated = origin == BattleOrigin.Overworld,
|
||||
Kind = config.Kind,
|
||||
CycleCount = config.CycleCount,
|
||||
MonsterWaves = config.Monsters is { Count: > 0 } ? config.Monsters : null,
|
||||
Blessings = config.Blessings,
|
||||
PathResonanceId = config.Kind == BattleKind.SimulatedUniverse ? config.PathResonanceId : 0,
|
||||
CustomStats = config.CustomStats,
|
||||
}, origin, monsters, cocoonId: cocoonId, wave: wave, farmId: farmId, eventId: eventId);
|
||||
}
|
||||
|
||||
private BattleInstance StartChallenge(List<SceneEntity> monsters, uint casterIndex, uint skillIndex, bool ambush)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
var request = new BattleRequest
|
||||
{
|
||||
StageId = state.StageId,
|
||||
Lineup = ResolveLineup(state.Lineup),
|
||||
LeaderSlot = casterIndex,
|
||||
SkillIndex = skillIndex,
|
||||
Ambush = ambush,
|
||||
PlayerInitiated = monsters.Count > 0,
|
||||
Kind = state.IsPeak ? BattleKind.AnomalyArbitration : state.Kind switch
|
||||
{
|
||||
ChallengeKind.PureFiction => BattleKind.PureFiction,
|
||||
ChallengeKind.ApocalypticShadow => BattleKind.ApocalypticShadow,
|
||||
_ => BattleKind.Moc,
|
||||
},
|
||||
CycleCount = state.Kind == ChallengeKind.MemoryOfChaos && !state.IsPeak ? Math.Max(1, state.RoundsLeft) : state.CycleCount,
|
||||
Blessings = [.. state.Blessings.Select(id => new SrBlessing { Id = id, Level = 1 })],
|
||||
ChallengeScore = state.TotalScore,
|
||||
};
|
||||
|
||||
if (state.Kind == ChallengeKind.PureFiction && World.Data.ChallengeStoryExtras.TryGetValue(state.ChallengeId, out var extra))
|
||||
{
|
||||
request.ScoreTargets = extra.BattleTargetID;
|
||||
}
|
||||
|
||||
foreach (var targetId in state.PeakTargets)
|
||||
{
|
||||
var param = World.Data.BattleTargets.GetValueOrDefault(targetId)?.TargetParam ?? 0;
|
||||
request.PeakTargets.Add((targetId, param));
|
||||
}
|
||||
|
||||
return Start(request, BattleOrigin.Challenge, monsters);
|
||||
}
|
||||
|
||||
private BattleInstance Start(
|
||||
BattleRequest request, BattleOrigin origin, List<SceneEntity> monsters,
|
||||
uint cocoonId = 0, uint wave = 0, uint farmId = 0, uint collegeId = 0, uint eventId = 0)
|
||||
{
|
||||
// /stage: one fight plays the requested stage, whatever started it
|
||||
if (origin != BattleOrigin.Challenge && Player.NextStageOverride != 0)
|
||||
{
|
||||
request.StageId = Player.NextStageOverride;
|
||||
request.ExtraStageIds = [];
|
||||
request.MonsterWaves = null;
|
||||
request.TrialAvatars = TrialAvatarsFor(World.Data.GetStage(request.StageId));
|
||||
Player.NextStageOverride = 0;
|
||||
}
|
||||
|
||||
request.BattleId = ++_nextBattleId;
|
||||
request.WorldLevel = Player.WorldLevel;
|
||||
request.Gender = Player.Gender;
|
||||
request.StateOf = id => Player.StateOf(id, SrTools);
|
||||
request.IsEnhanced = id => Player.IsEnhanced(id, SrTools);
|
||||
|
||||
var info = World.Battles.Build(request, SrTools);
|
||||
|
||||
var instance = new BattleInstance
|
||||
{
|
||||
BattleId = request.BattleId,
|
||||
StageId = request.StageId,
|
||||
Origin = origin,
|
||||
Kind = request.Kind,
|
||||
Info = info,
|
||||
Lineup = [.. request.Lineup],
|
||||
MonsterEntityIds = [.. monsters.Select(m => m.EntityId)],
|
||||
EventId = eventId,
|
||||
CocoonId = cocoonId,
|
||||
Wave = wave,
|
||||
FarmElementId = farmId,
|
||||
CollegeId = collegeId,
|
||||
UsesTrialAvatars = request.TrialAvatars.Count > 0,
|
||||
};
|
||||
|
||||
Current = instance;
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"battle {Id} ({Origin}): stage {Stage}, {Avatars} avatars, {Waves} waves, {Buffs} buffs, {Monsters} scene monsters",
|
||||
instance.BattleId, origin, instance.StageId, info.BattleAvatarList.Count,
|
||||
info.MonsterWaveList.Count, info.BuffList.Count, instance.MonsterEntityIds.Count);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// the client tells us how it went; the world catches up with it
|
||||
public async Task<PVEBattleResultScRsp> FinishAsync(PVEBattleResultCsReq request)
|
||||
{
|
||||
var battle = Current;
|
||||
Current = null;
|
||||
LastEndStatus = request.EndStatus;
|
||||
|
||||
var response = new PVEBattleResultScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
BattleId = request.BattleId,
|
||||
StageId = request.StageId,
|
||||
EndStatus = request.EndStatus,
|
||||
CheckIdentical = false,
|
||||
EventId = battle?.EventId ?? 0,
|
||||
DropData = new ItemList(),
|
||||
MultipleDropData = new ItemList(),
|
||||
ItemListUnk1 = new ItemList(),
|
||||
ItemListUnk2 = new ItemList(),
|
||||
};
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"battle {Id} stage {Stage} ended {Status} after {Rounds} rounds ({Ms} ms)",
|
||||
request.BattleId, request.StageId, request.EndStatus, request.Stt?.RoundCnt ?? 0, request.CostTime);
|
||||
|
||||
if (battle is null)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!battle.UsesTrialAvatars && request.EndStatus != BattleEndStatus.BattleEndQuit)
|
||||
{
|
||||
ApplyAvatarStatus(request);
|
||||
}
|
||||
|
||||
switch (request.EndStatus)
|
||||
{
|
||||
case BattleEndStatus.BattleEndWin:
|
||||
await session.RemoveEntitiesAsync(battle.MonsterEntityIds);
|
||||
|
||||
if (battle.CollegeId != 0 && Player.FinishedColleges.Add(battle.CollegeId))
|
||||
{
|
||||
Player.Touch();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case BattleEndStatus.BattleEndLose:
|
||||
// the party limps away rather than lying dead in the overworld
|
||||
foreach (var state in Player.AvatarStates.Values)
|
||||
{
|
||||
state.Hp = Math.Max(state.Hp, 2000);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (battle.IsChallenge && session.Challenge.Active)
|
||||
{
|
||||
await ChallengeFlow.OnBattleEndAsync(session, battle, request);
|
||||
}
|
||||
|
||||
await session.SyncLineupAsync();
|
||||
return response;
|
||||
}
|
||||
|
||||
private void ApplyAvatarStatus(PVEBattleResultCsReq request)
|
||||
{
|
||||
if (request.Stt is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var avatar in request.Stt.BattleAvatarList)
|
||||
{
|
||||
if (avatar.AvatarType != AvatarType.AvatarFormalType || avatar.AvatarStatus is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var status = avatar.AvatarStatus;
|
||||
var state = Player.StateOf(avatar.Id, SrTools);
|
||||
|
||||
if (status.MaxHp > 0)
|
||||
{
|
||||
var ratio = Math.Clamp(status.LeftHp / status.MaxHp, 0, 1);
|
||||
state.Hp = (uint)Math.Round(ratio * Player.FullHp);
|
||||
}
|
||||
|
||||
if (status.MaxSp > 0)
|
||||
{
|
||||
state.SpMax = (uint)Math.Round(status.MaxSp);
|
||||
state.SpCur = (uint)Math.Round(Math.Clamp(status.LeftSp, 0, status.MaxSp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// story fights lend their own characters; the roster stays out of it
|
||||
private List<SpecialAvatarExcel> TrialAvatarsFor(StageExcel? stage) =>
|
||||
stage is null
|
||||
? []
|
||||
: [.. stage.TrialAvatarList.Select(id => World.Data.SpecialAvatars.GetValueOrDefault(id)).OfType<SpecialAvatarExcel>()];
|
||||
|
||||
// lineups hold base ids; the fight wants the path actually equipped
|
||||
private List<uint> ResolveLineup(IEnumerable<uint> avatarIds) =>
|
||||
[.. avatarIds.Where(id => id != 0).Select(id => Roster.ResolvePath(World, Player, id)).Distinct()];
|
||||
|
||||
private int IndexOfBase(List<uint> lineup, uint baseAvatarId)
|
||||
{
|
||||
if (baseAvatarId == 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (var i = 0; i < lineup.Count; i++)
|
||||
{
|
||||
if (World.Data.BaseAvatarId(lineup[i]) == baseAvatarId)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Battle;
|
||||
|
||||
public enum BattleOrigin
|
||||
{
|
||||
Overworld,
|
||||
Stage,
|
||||
Cocoon,
|
||||
FarmElement,
|
||||
College,
|
||||
Challenge,
|
||||
}
|
||||
|
||||
// one fight from the moment the client is told about it until it reports the result
|
||||
public sealed class BattleInstance
|
||||
{
|
||||
public required uint BattleId { get; init; }
|
||||
|
||||
public required uint StageId { get; init; }
|
||||
|
||||
public required BattleOrigin Origin { get; init; }
|
||||
|
||||
public BattleKind Kind { get; init; } = BattleKind.Default;
|
||||
|
||||
public required SceneBattleInfo Info { get; init; }
|
||||
|
||||
public List<uint> Lineup { get; init; } = [];
|
||||
|
||||
// scene entities that die when the fight is won
|
||||
public List<uint> MonsterEntityIds { get; init; } = [];
|
||||
|
||||
public uint EventId { get; init; }
|
||||
|
||||
public uint CocoonId { get; init; }
|
||||
|
||||
public uint Wave { get; init; }
|
||||
|
||||
public uint FarmElementId { get; init; }
|
||||
|
||||
public uint CollegeId { get; init; }
|
||||
|
||||
public bool UsesTrialAvatars { get; init; }
|
||||
|
||||
public bool IsChallenge => Origin == BattleOrigin.Challenge;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Battle;
|
||||
|
||||
// PF scores against a target object; AS tracks a boss gauge; AA counts its own targets.
|
||||
// wrong or missing targets leave the client with no win condition, so it hangs at the end.
|
||||
public static class BattleTargets
|
||||
{
|
||||
public static void Apply(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
switch (Resolve(request))
|
||||
{
|
||||
case BattleKind.PureFiction:
|
||||
ApplyPureFiction(battle, request);
|
||||
break;
|
||||
|
||||
case BattleKind.ApocalypticShadow:
|
||||
// the boss gauge starts empty; its final progress is the node's score
|
||||
battle.BattleTargetInfo[1] = List(new BattleTarget { Id = 90005, Progress = 0 });
|
||||
break;
|
||||
|
||||
case BattleKind.AnomalyArbitration:
|
||||
ApplyPeak(battle, request);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// the explicit setting wins; stage id ranges are only a fallback
|
||||
private static BattleKind Resolve(BattleRequest request)
|
||||
{
|
||||
if (request.Kind is not BattleKind.Default)
|
||||
{
|
||||
return request.Kind;
|
||||
}
|
||||
|
||||
return request.StageId switch
|
||||
{
|
||||
>= 30019000 and <= 30019100 => BattleKind.PureFiction,
|
||||
>= 30021000 and <= 30021100 => BattleKind.PureFiction,
|
||||
>= 30301000 and <= 30399900 => BattleKind.PureFiction,
|
||||
>= 420100 and <= 420900 => BattleKind.ApocalypticShadow,
|
||||
_ => BattleKind.Default,
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyPureFiction(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
battle.BattleTargetInfo[1] = List(new BattleTarget
|
||||
{
|
||||
Id = battle.StageId >= 30309011 ? 10003u : 10002u,
|
||||
Progress = request.ChallengeScore,
|
||||
TotalProgress = 80000,
|
||||
});
|
||||
|
||||
for (uint i = 2; i <= 4; i++)
|
||||
{
|
||||
battle.BattleTargetInfo[i] = new BattleTargetList();
|
||||
}
|
||||
|
||||
var scoreTargets = request.ScoreTargets.Count > 0 ? request.ScoreTargets : [2001, 2002];
|
||||
|
||||
battle.BattleTargetInfo[5] = List(
|
||||
[.. scoreTargets.Select(id => new BattleTarget { Id = id, Progress = request.ChallengeScore })]);
|
||||
}
|
||||
|
||||
private static void ApplyPeak(SceneBattleInfo battle, BattleRequest request)
|
||||
{
|
||||
if (request.PeakTargets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
battle.BattleTargetInfo[5] = List(
|
||||
[.. request.PeakTargets.Select(t => new BattleTarget { Id = t.Id, Progress = 0, TotalProgress = t.Param })]);
|
||||
}
|
||||
|
||||
private static BattleTargetList List(params BattleTarget[] targets)
|
||||
{
|
||||
var list = new BattleTargetList();
|
||||
list.BattleTargetList_.AddRange(targets);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using CapySR.Data;
|
||||
|
||||
namespace CapySR.GameServer.Battle;
|
||||
|
||||
public readonly record struct TechniqueBuff(uint Id, float SkillIndex, bool OwnedByLeader = false);
|
||||
|
||||
// technique buffs come from AvatarDefaultMazeBuff. the overrides below are the extras that
|
||||
// table does not express, plus characters missing from it entirely (beta-only ones).
|
||||
public static class TechniqueBuffs
|
||||
{
|
||||
private const uint IgnoreToughnessBuff = 1000119;
|
||||
|
||||
// female MC/March paths reuse the male path's buff ids
|
||||
private static uint BuffOwner(uint avatarId) => avatarId switch
|
||||
{
|
||||
8004 => 8003,
|
||||
8006 => 8005,
|
||||
8008 => 8007,
|
||||
8010 => 8009,
|
||||
_ => avatarId,
|
||||
};
|
||||
|
||||
// extra buffs layered on top of whatever the data gives
|
||||
private static readonly Dictionary<uint, TechniqueBuff[]> Extras = new()
|
||||
{
|
||||
[1208] = [new TechniqueBuff(120801, 0)],
|
||||
[1224] = [new TechniqueBuff(122403, 0), new TechniqueBuff(122401, 0)],
|
||||
[1306] = [new TechniqueBuff(130601, 0)],
|
||||
[1309] = [new TechniqueBuff(130901, 0)],
|
||||
[1310] = [new TechniqueBuff(1000112, 0)],
|
||||
[1412] = [new TechniqueBuff(1000121, 0, OwnedByLeader: true)],
|
||||
[1414] = [new TechniqueBuff(1000121, 0, OwnedByLeader: true)],
|
||||
[1510] = [new TechniqueBuff(151001, 0)],
|
||||
};
|
||||
|
||||
// avatars absent from AvatarDefaultMazeBuff - beta content the prod dump has not caught up to
|
||||
private static readonly Dictionary<uint, TechniqueBuff[]> Fallbacks = new()
|
||||
{
|
||||
[1503] = [new TechniqueBuff(150301, 0), new TechniqueBuff(1000121, 0, OwnedByLeader: true)],
|
||||
};
|
||||
|
||||
// techniques that already ignore toughness, so we must not also add the element buff
|
||||
private static readonly HashSet<uint> IgnoresToughness = [1006, 1308, 1317, 1405];
|
||||
|
||||
// techniques that defeat ordinary enemies outright instead of starting a fight
|
||||
private static readonly HashSet<uint> FodderKillers = [1308, 1408, 1506, 1510];
|
||||
|
||||
public static bool IgnoresToughnessOnEntry(uint avatarId) => IgnoresToughness.Contains(BuffOwner(avatarId));
|
||||
|
||||
public static bool KillsFodder(uint avatarId) => FodderKillers.Contains(BuffOwner(avatarId));
|
||||
|
||||
public static IEnumerable<TechniqueBuff> For(GameData data, uint avatarId)
|
||||
{
|
||||
var owner = BuffOwner(avatarId);
|
||||
|
||||
if (IgnoresToughness.Contains(owner))
|
||||
{
|
||||
yield return new TechniqueBuff(IgnoreToughnessBuff, 0);
|
||||
}
|
||||
|
||||
if (Fallbacks.TryGetValue(owner, out var fallback))
|
||||
{
|
||||
foreach (var buff in fallback)
|
||||
{
|
||||
yield return buff;
|
||||
}
|
||||
}
|
||||
else if (data.DefaultMazeBuffs.TryGetValue(owner, out var row) && row.DefaultMazeBuffIDList.Count > 0)
|
||||
{
|
||||
foreach (var id in row.DefaultMazeBuffIDList)
|
||||
{
|
||||
yield return new TechniqueBuff(id, row.SkillIndex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// the shape the client expects when nothing else is known
|
||||
yield return new TechniqueBuff((owner * 100) + 1, 0);
|
||||
}
|
||||
|
||||
if (Extras.TryGetValue(owner, out var extras))
|
||||
{
|
||||
foreach (var buff in extras)
|
||||
{
|
||||
yield return buff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the buff that breaks enemy toughness when this element starts the fight
|
||||
public static uint AttackerBuff(string damageType) => damageType switch
|
||||
{
|
||||
"Physical" => 1000111,
|
||||
"Fire" => 1000112,
|
||||
"Ice" => 1000113,
|
||||
"Thunder" => 1000114,
|
||||
"Wind" => 1000115,
|
||||
"Quantum" => 1000116,
|
||||
"Imaginary" => 1000117,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
|
||||
<ProjectReference Include="..\CapySR.Data\CapySR.Data.csproj" />
|
||||
<ProjectReference Include="..\CapySR.Kcp\CapySR.Kcp.csproj" />
|
||||
<ProjectReference Include="..\CapySR.Protocol\CapySR.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,318 +0,0 @@
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// the floor the session is running: a MOC/PF/AS floor with up to two nodes, or an AA stage
|
||||
public sealed class ChallengeState
|
||||
{
|
||||
public bool Active { get; private set; }
|
||||
|
||||
public bool IsPeak { get; private set; }
|
||||
|
||||
public uint ChallengeId { get; private set; }
|
||||
|
||||
public ChallengeKind Kind { get; private set; }
|
||||
|
||||
// 0 = first half, 1 = second half
|
||||
public int Node { get; private set; }
|
||||
|
||||
// a toggle the client sets before starting, not per-run state, so Clear leaves it alone
|
||||
public bool HardMode { get; set; }
|
||||
|
||||
public ChallengeMazeExcel? Config { get; private set; }
|
||||
|
||||
public ChallengePeakExcel? Peak { get; private set; }
|
||||
|
||||
public ChallengePeakBossExcel? Boss { get; private set; }
|
||||
|
||||
public List<uint>[] Lineups { get; } = [[], []];
|
||||
|
||||
public uint[] SelectedBuffs { get; } = new uint[2];
|
||||
|
||||
public uint[] Scores { get; } = new uint[2];
|
||||
|
||||
public uint MazeBuffId { get; private set; }
|
||||
|
||||
public uint Stars { get; private set; }
|
||||
|
||||
public uint RoundsLeft { get; set; }
|
||||
|
||||
public uint DeadAvatars { get; set; }
|
||||
|
||||
public uint SavedMp { get; set; }
|
||||
|
||||
public ChallengeStatus Status { get; private set; } = ChallengeStatus.ChallengeUnknown;
|
||||
|
||||
// AA: the battle targets in play, so stars can be read back from the result
|
||||
public List<uint> PeakTargets { get; } = [];
|
||||
|
||||
public uint StageId { get; private set; }
|
||||
|
||||
public uint CycleCount { get; private set; } = 30;
|
||||
|
||||
public uint FloorId { get; private set; }
|
||||
|
||||
public uint PlaneId { get; private set; }
|
||||
|
||||
public uint WorldId { get; private set; }
|
||||
|
||||
public uint EntryId { get; private set; }
|
||||
|
||||
public uint GroupId { get; private set; }
|
||||
|
||||
public uint MonsterId { get; private set; }
|
||||
|
||||
public uint EventId { get; private set; }
|
||||
|
||||
public List<uint> Lineup => Lineups[Node];
|
||||
|
||||
public int NodeCount => IsPeak ? 1 : Config?.NodeCount ?? 1;
|
||||
|
||||
public bool IsLastNode => Node >= NodeCount - 1;
|
||||
|
||||
public bool Finished => Status is ChallengeStatus.ChallengeFinish or ChallengeStatus.ChallengeFailed;
|
||||
|
||||
public uint TotalScore => Scores[0] + Scores[1];
|
||||
|
||||
public ExtraLineupType LineupType => Node == 0 ? ExtraLineupType.LineupChallenge : ExtraLineupType.LineupChallenge2;
|
||||
|
||||
// the maze buff always applies; the player's pick for the node is layered on top
|
||||
public List<uint> Blessings
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = new List<uint>();
|
||||
|
||||
if (IsPeak)
|
||||
{
|
||||
if (SelectedBuffs[0] != 0)
|
||||
{
|
||||
list.Add(SelectedBuffs[0]);
|
||||
}
|
||||
|
||||
list.AddRange(HardMode && Boss is not null ? Boss.HardTagList : Peak?.TagList ?? []);
|
||||
return list;
|
||||
}
|
||||
|
||||
if (MazeBuffId != 0)
|
||||
{
|
||||
list.Add(MazeBuffId);
|
||||
}
|
||||
|
||||
if (SelectedBuffs[Node] != 0)
|
||||
{
|
||||
list.Add(SelectedBuffs[Node]);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Active = false;
|
||||
IsPeak = false;
|
||||
ChallengeId = 0;
|
||||
Node = 0;
|
||||
Config = null;
|
||||
Peak = null;
|
||||
Boss = null;
|
||||
Lineups[0].Clear();
|
||||
Lineups[1].Clear();
|
||||
Array.Clear(SelectedBuffs);
|
||||
Array.Clear(Scores);
|
||||
MazeBuffId = 0;
|
||||
Stars = 0;
|
||||
RoundsLeft = 0;
|
||||
DeadAvatars = 0;
|
||||
SavedMp = 0;
|
||||
Status = ChallengeStatus.ChallengeUnknown;
|
||||
PeakTargets.Clear();
|
||||
StageId = 0;
|
||||
CycleCount = 30;
|
||||
FloorId = PlaneId = WorldId = EntryId = GroupId = MonsterId = EventId = 0;
|
||||
}
|
||||
|
||||
// single-node convenience: the node's lineup and pick, nothing for the other half
|
||||
public bool StartChallenge(GameData data, ChallengeMazeExcel challenge, int node, uint selectedBuff, IEnumerable<uint> lineup)
|
||||
{
|
||||
var first = node == 0 ? lineup : [];
|
||||
var second = node == 1 ? lineup : [];
|
||||
|
||||
return Start(data, challenge, first, second, node == 0 ? selectedBuff : 0, node == 1 ? selectedBuff : 0)
|
||||
&& (node == 0 || SwitchNode(data, 1));
|
||||
}
|
||||
|
||||
public bool Start(
|
||||
GameData data, ChallengeMazeExcel challenge,
|
||||
IEnumerable<uint> first, IEnumerable<uint> second, uint buffOne, uint buffTwo)
|
||||
{
|
||||
Clear();
|
||||
|
||||
Config = challenge;
|
||||
ChallengeId = challenge.ID;
|
||||
Kind = challenge.Kind;
|
||||
MazeBuffId = challenge.MazeBuffID;
|
||||
SelectedBuffs[0] = buffOne;
|
||||
SelectedBuffs[1] = buffTwo;
|
||||
Lineups[0].AddRange(first.Where(id => id != 0).Distinct().Take(4));
|
||||
Lineups[1].AddRange(second.Where(id => id != 0).Distinct().Take(4));
|
||||
CycleCount = challenge.ChallengeCountDown == 0 ? 30 : challenge.ChallengeCountDown;
|
||||
RoundsLeft = CycleCount;
|
||||
Status = ChallengeStatus.ChallengeDoing;
|
||||
|
||||
if (!ResolveNode(data, 0))
|
||||
{
|
||||
Clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
Active = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SwitchNode(GameData data, int node)
|
||||
{
|
||||
if (Config is null || node < 0 || node >= NodeCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ResolveNode(data, node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Node = node;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool ResolveNode(GameData data, int node)
|
||||
{
|
||||
var challenge = Config!;
|
||||
var useSecond = node == 1 && challenge.HasSecondNode;
|
||||
|
||||
var entranceId = useSecond && challenge.MapEntranceID2 != 0 ? challenge.MapEntranceID2 : challenge.MapEntranceID;
|
||||
var monsters = useSecond ? challenge.NpcMonsterIDList2 : challenge.NpcMonsterIDList1;
|
||||
var events = useSecond ? challenge.EventIDList2 : challenge.EventIDList1;
|
||||
var group = useSecond ? challenge.MazeGroupID2 ?? challenge.MazeGroupID1 : challenge.MazeGroupID1;
|
||||
|
||||
if (monsters.Count == 0 || events.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.ResolveEntrance(entranceId) is not { } resolved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// the last entry is the real encounter; earlier ones are approach mobs
|
||||
MonsterId = monsters[^1];
|
||||
EventId = events[^1];
|
||||
StageId = EventId;
|
||||
|
||||
EntryId = entranceId;
|
||||
FloorId = resolved.Entrance.FloorID;
|
||||
PlaneId = resolved.Plane?.ChallengePlaneID is > 0 ? resolved.Plane.ChallengePlaneID : resolved.Entrance.PlaneID;
|
||||
WorldId = resolved.Plane?.WorldID ?? 0;
|
||||
GroupId = group;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool StartPeak(GameData data, ChallengePeakExcel peak, ChallengePeakBossExcel? boss, uint selectedBuff, IEnumerable<uint> lineup)
|
||||
{
|
||||
Clear();
|
||||
|
||||
if (!peak.IsPlayable || data.ResolveEntrance(peak.MapEntranceID) is not { } resolved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Active = true;
|
||||
IsPeak = true;
|
||||
Peak = peak;
|
||||
Boss = boss;
|
||||
ChallengeId = peak.ID;
|
||||
Kind = ChallengeKind.ApocalypticShadow;
|
||||
Status = ChallengeStatus.ChallengeDoing;
|
||||
SelectedBuffs[0] = selectedBuff;
|
||||
Lineups[0].AddRange(lineup.Where(id => id != 0).Distinct().Take(4));
|
||||
|
||||
// hard mode swaps the whole tag set, and can swap the encounter too
|
||||
var events = peak.EventIDList;
|
||||
|
||||
if (HardMode && boss is not null && boss.HardEventIDList.Count > 0)
|
||||
{
|
||||
events = boss.HardEventIDList;
|
||||
}
|
||||
|
||||
if (HardMode && boss is not null && boss.HardTarget != 0)
|
||||
{
|
||||
PeakTargets.Add(boss.HardTarget);
|
||||
}
|
||||
else
|
||||
{
|
||||
PeakTargets.AddRange(peak.NormalTargetList);
|
||||
}
|
||||
|
||||
MonsterId = peak.NpcMonsterIDList[^1];
|
||||
EventId = events[^1];
|
||||
StageId = EventId;
|
||||
|
||||
EntryId = peak.MapEntranceID;
|
||||
FloorId = resolved.Entrance.FloorID;
|
||||
PlaneId = resolved.Plane?.ChallengePlaneID is > 0 ? resolved.Plane.ChallengePlaneID : resolved.Entrance.PlaneID;
|
||||
WorldId = resolved.Plane?.WorldID ?? 0;
|
||||
GroupId = peak.MazeGroupID;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Finish(uint stars)
|
||||
{
|
||||
Stars = stars;
|
||||
Status = ChallengeStatus.ChallengeFinish;
|
||||
}
|
||||
|
||||
public void Fail() => Status = ChallengeStatus.ChallengeFailed;
|
||||
|
||||
// MOC stars are rounds and deaths, PF/AS stars are total score
|
||||
public uint CalculateStars(GameData data)
|
||||
{
|
||||
if (Config is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var stars = 0u;
|
||||
var targets = Config.ChallengeTargetID;
|
||||
|
||||
for (var i = 0; i < targets.Count && i < 3; i++)
|
||||
{
|
||||
if (!data.ChallengeTargets.TryGetValue(targets[i], out var target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var met = target.ChallengeTargetType switch
|
||||
{
|
||||
"ROUNDS_LEFT" => RoundsLeft >= target.ChallengeTargetParam1,
|
||||
"DEAD_AVATAR" => DeadAvatars == 0,
|
||||
"TOTAL_SCORE" => TotalScore >= target.ChallengeTargetParam1,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (met)
|
||||
{
|
||||
stars |= 1u << i;
|
||||
}
|
||||
}
|
||||
|
||||
// a floor with no target rows still counts a win as full marks
|
||||
return targets.Count == 0 ? 7 : stars;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Battle;
|
||||
using CapySR.GameServer.Scene;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// shared, read-mostly server state: static tables plus the srtools file, reloaded on change
|
||||
public sealed class GameWorld
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _srToolsPath;
|
||||
private FileSystemWatcher? _watcher;
|
||||
private DateTime _lastLoad = DateTime.MinValue;
|
||||
|
||||
public GameWorld(ServerConfig config, ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_srToolsPath = SrToolsData.ResolvePath(RepoPaths.Root);
|
||||
PlayerStorePath = Path.Combine(RepoPaths.Data, PlayerStore.FileName);
|
||||
|
||||
Data = GameData.Load(config.Data.ResolvedSources, m => logger.LogInformation("{Message}", m));
|
||||
Battles = new BattleBuilder(Data);
|
||||
Scenes = new SceneBuilder(Data);
|
||||
Player = LoadPlayer();
|
||||
OwnedAvatarIds = ComputeOwned(Player);
|
||||
WatchPlayerFile();
|
||||
}
|
||||
|
||||
public GameData Data { get; }
|
||||
|
||||
public BattleBuilder Battles { get; }
|
||||
|
||||
public SceneBuilder Scenes { get; }
|
||||
|
||||
public SrToolsData Player { get; private set; }
|
||||
|
||||
public string PlayerStorePath { get; }
|
||||
|
||||
// sorted; the roster the account owns right now
|
||||
public IReadOnlyList<uint> OwnedAvatarIds { get; private set; }
|
||||
|
||||
public bool HasSrToolsRoster => Player.Avatars.Count > 0;
|
||||
|
||||
public event Action? PlayerReloaded;
|
||||
|
||||
private IReadOnlyList<uint> ComputeOwned(SrToolsData player) =>
|
||||
player.Avatars.Count > 0
|
||||
? [.. player.Avatars.Keys.Where(id => Roster.IsPlayable(id) && Data.Avatars.ContainsKey(id)).OrderBy(id => id)]
|
||||
: [.. Data.Avatars.Keys.Where(Roster.IsPlayable).OrderBy(id => id)];
|
||||
|
||||
private SrToolsData LoadPlayer()
|
||||
{
|
||||
if (!File.Exists(_srToolsPath))
|
||||
{
|
||||
_logger.LogWarning("no {Path} yet - upload from srtools to populate avatars", _srToolsPath);
|
||||
return SrToolsData.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = SrToolsData.Load(_srToolsPath);
|
||||
DropUnknownItems(data);
|
||||
|
||||
_logger.LogInformation(
|
||||
"srtools data: {Avatars} avatars, {Relics} relics, {Lightcones} lightcones, stage {Stage}",
|
||||
data.Avatars.Count, data.Relics.Count, data.Lightcones.Count, data.BattleConfig.StageId);
|
||||
|
||||
return data;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or System.Text.Json.JsonException)
|
||||
{
|
||||
_logger.LogError("could not read freesr-data.json: {Message}", e.Message);
|
||||
return Player ?? SrToolsData.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// srtools can hand out items newer than the client knows. an item whose type the client
|
||||
// cannot resolve makes its inventory red-dot filter throw every frame, which freezes the
|
||||
// character screen, so drop them before they ever reach the wire.
|
||||
private void DropUnknownItems(SrToolsData player)
|
||||
{
|
||||
if (Data.KnownRelicIds.Count == 0 && Data.KnownLightconeIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relics = player.Relics.RemoveAll(r => !Data.KnownRelicIds.Contains(r.RelicId));
|
||||
var lightcones = player.Lightcones.RemoveAll(l => !Data.KnownLightconeIds.Contains(l.ItemId));
|
||||
var avatars = player.Avatars.Keys.Where(id => !Data.Avatars.ContainsKey(id)).ToList();
|
||||
|
||||
foreach (var id in avatars)
|
||||
{
|
||||
player.Avatars.Remove(id);
|
||||
}
|
||||
|
||||
if (relics + lightcones + avatars.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"dropped {Relics} relic(s), {Lightcones} lightcone(s) and {Avatars} avatar(s) the game data does not know",
|
||||
relics, lightcones, avatars.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private void WatchPlayerFile()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_srToolsPath)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
_watcher = new FileSystemWatcher(directory, Path.GetFileName(_srToolsPath))
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
|
||||
_watcher.Changed += OnChanged;
|
||||
_watcher.Created += OnChanged;
|
||||
_watcher.Renamed += (_, _) => OnChanged(null!, null!);
|
||||
}
|
||||
|
||||
private void OnChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// editors and our own atomic move fire several events for one save
|
||||
if (DateTime.UtcNow - _lastLoad < TimeSpan.FromMilliseconds(500))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastLoad = DateTime.UtcNow;
|
||||
Thread.Sleep(100);
|
||||
|
||||
Reload();
|
||||
PlayerReloaded?.Invoke();
|
||||
}
|
||||
|
||||
// re-read the srtools file on demand; callers decide who gets told
|
||||
public void Reload()
|
||||
{
|
||||
var player = LoadPlayer();
|
||||
Player = player;
|
||||
OwnedAvatarIds = ComputeOwned(player);
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// One squad. Four slots that may have holes, exactly the way the client addresses them:
|
||||
// every lineup packet carries a slot index, and the team screen redraws from whatever we
|
||||
// echo back. The leader is remembered by avatar id so it follows the character when the
|
||||
// team is swapped around.
|
||||
public sealed class Lineup
|
||||
{
|
||||
public const int Size = 4;
|
||||
|
||||
private readonly uint[] _slots = new uint[Size];
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public uint LeaderAvatarId { get; private set; }
|
||||
|
||||
public IReadOnlyList<uint> Slots => _slots;
|
||||
|
||||
public IEnumerable<(uint Slot, uint AvatarId)> Occupied =>
|
||||
_slots.Select((id, i) => ((uint)i, id)).Where(s => s.id != 0);
|
||||
|
||||
// packed order, the way battles and scenes want it
|
||||
public List<uint> AvatarIds => [.. _slots.Where(id => id != 0)];
|
||||
|
||||
public int Count => _slots.Count(id => id != 0);
|
||||
|
||||
public bool IsEmpty => Count == 0;
|
||||
|
||||
public uint LeaderSlot
|
||||
{
|
||||
get
|
||||
{
|
||||
var index = Array.IndexOf(_slots, LeaderAvatarId);
|
||||
return index >= 0 ? (uint)index : FirstOccupiedSlot();
|
||||
}
|
||||
}
|
||||
|
||||
// position of the leader among the occupied slots
|
||||
public uint LeaderIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var ids = AvatarIds;
|
||||
var index = ids.IndexOf(LeaderAvatarId);
|
||||
return index >= 0 ? (uint)index : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Contains(uint avatarId) => avatarId != 0 && Array.IndexOf(_slots, avatarId) >= 0;
|
||||
|
||||
public uint At(uint slot) => slot < Size ? _slots[slot] : 0;
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Array.Clear(_slots);
|
||||
LeaderAvatarId = 0;
|
||||
}
|
||||
|
||||
public void Fill(IEnumerable<uint> avatarIds)
|
||||
{
|
||||
Clear();
|
||||
|
||||
var next = 0;
|
||||
foreach (var id in avatarIds.Where(id => id != 0).Distinct().Take(Size))
|
||||
{
|
||||
_slots[next++] = id;
|
||||
}
|
||||
|
||||
NormaliseLeader();
|
||||
}
|
||||
|
||||
public void Replace(IEnumerable<(uint Slot, uint AvatarId)> entries, uint leaderSlot)
|
||||
{
|
||||
Array.Clear(_slots);
|
||||
|
||||
foreach (var (slot, avatarId) in entries)
|
||||
{
|
||||
if (slot < Size && avatarId != 0 && Array.IndexOf(_slots, avatarId) < 0)
|
||||
{
|
||||
_slots[slot] = avatarId;
|
||||
}
|
||||
}
|
||||
|
||||
LeaderAvatarId = leaderSlot < Size ? _slots[leaderSlot] : 0;
|
||||
NormaliseLeader();
|
||||
}
|
||||
|
||||
public Retcode Join(uint avatarId, uint slot)
|
||||
{
|
||||
if (avatarId == 0)
|
||||
{
|
||||
return Retcode.RetLineupAvatarNotExist;
|
||||
}
|
||||
|
||||
if (slot >= Size)
|
||||
{
|
||||
return Retcode.RetLineupInvalidMemberPos;
|
||||
}
|
||||
|
||||
var current = Array.IndexOf(_slots, avatarId);
|
||||
if (current == (int)slot)
|
||||
{
|
||||
return Retcode.RetLineupAvatarAlreadyIn;
|
||||
}
|
||||
|
||||
// joining an occupied slot swaps with whoever sat there
|
||||
if (current >= 0)
|
||||
{
|
||||
_slots[current] = _slots[slot];
|
||||
}
|
||||
|
||||
_slots[slot] = avatarId;
|
||||
NormaliseLeader();
|
||||
return Retcode.RetSucc;
|
||||
}
|
||||
|
||||
public Retcode Quit(uint avatarId)
|
||||
{
|
||||
var index = Array.IndexOf(_slots, avatarId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return Retcode.RetLineupAvatarNotExist;
|
||||
}
|
||||
|
||||
if (Count <= 1)
|
||||
{
|
||||
return Retcode.RetLineupOnlyOneMember;
|
||||
}
|
||||
|
||||
_slots[index] = 0;
|
||||
NormaliseLeader();
|
||||
return Retcode.RetSucc;
|
||||
}
|
||||
|
||||
public Retcode Swap(uint a, uint b)
|
||||
{
|
||||
if (a >= Size || b >= Size)
|
||||
{
|
||||
return Retcode.RetLineupInvalidMemberPos;
|
||||
}
|
||||
|
||||
if (a == b)
|
||||
{
|
||||
return Retcode.RetLineupSwapSameSlot;
|
||||
}
|
||||
|
||||
(_slots[a], _slots[b]) = (_slots[b], _slots[a]);
|
||||
return Retcode.RetSucc;
|
||||
}
|
||||
|
||||
public Retcode SetLeader(uint slot)
|
||||
{
|
||||
if (slot >= Size || _slots[slot] == 0)
|
||||
{
|
||||
return Retcode.RetLineupNotValidLeader;
|
||||
}
|
||||
|
||||
LeaderAvatarId = _slots[slot];
|
||||
return Retcode.RetSucc;
|
||||
}
|
||||
|
||||
public void SetLeaderAvatar(uint avatarId)
|
||||
{
|
||||
if (Contains(avatarId))
|
||||
{
|
||||
LeaderAvatarId = avatarId;
|
||||
}
|
||||
}
|
||||
|
||||
// drop anything the roster no longer owns, e.g. after a new srtools upload
|
||||
public bool Prune(Func<uint, bool> owned)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
for (var i = 0; i < Size; i++)
|
||||
{
|
||||
if (_slots[i] != 0 && !owned(_slots[i]))
|
||||
{
|
||||
_slots[i] = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
NormaliseLeader();
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
private uint FirstOccupiedSlot()
|
||||
{
|
||||
for (var i = 0; i < Size; i++)
|
||||
{
|
||||
if (_slots[i] != 0)
|
||||
{
|
||||
return (uint)i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void NormaliseLeader()
|
||||
{
|
||||
if (!Contains(LeaderAvatarId))
|
||||
{
|
||||
LeaderAvatarId = _slots[FirstOccupiedSlot()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every squad the client can edit by index, plus the virtual lineups a challenge swaps in.
|
||||
// Technique points are a shared pool, so they live here rather than on a squad.
|
||||
public sealed class LineupBook
|
||||
{
|
||||
public const int SquadCount = 6;
|
||||
public const uint BaseMaxMp = 5;
|
||||
|
||||
// Phainon and Himeko Nova each raise the cap by three while in the team
|
||||
private static readonly HashSet<uint> ExtraMpAvatars = [1408, 1510];
|
||||
|
||||
private readonly Lineup[] _squads =
|
||||
[.. Enumerable.Range(0, SquadCount).Select(i => new Lineup { Name = $"Squad {i + 1}" })];
|
||||
|
||||
private readonly Dictionary<ExtraLineupType, Lineup> _extra = [];
|
||||
|
||||
private uint _mp = BaseMaxMp;
|
||||
|
||||
public uint CurrentIndex { get; private set; }
|
||||
|
||||
public ExtraLineupType ExtraType { get; private set; } = ExtraLineupType.LineupNone;
|
||||
|
||||
public IReadOnlyList<Lineup> Squads => _squads;
|
||||
|
||||
public Lineup Squad(uint index) => _squads[Math.Min(index, SquadCount - 1)];
|
||||
|
||||
// the lineup that is actually walking around: a virtual one while it is active
|
||||
public Lineup Current =>
|
||||
ExtraType != ExtraLineupType.LineupNone && _extra.TryGetValue(ExtraType, out var extra)
|
||||
? extra
|
||||
: _squads[CurrentIndex];
|
||||
|
||||
public bool InExtraLineup => ExtraType != ExtraLineupType.LineupNone;
|
||||
|
||||
public uint MaxMp => Current.AvatarIds.Any(ExtraMpAvatars.Contains) ? BaseMaxMp + 3 : BaseMaxMp;
|
||||
|
||||
public uint Mp
|
||||
{
|
||||
get => Math.Min(_mp, MaxMp);
|
||||
set => _mp = Math.Min(value, MaxMp);
|
||||
}
|
||||
|
||||
public Retcode SetCurrent(uint index)
|
||||
{
|
||||
if (index >= SquadCount)
|
||||
{
|
||||
return Retcode.RetLineupInvalidIndex;
|
||||
}
|
||||
|
||||
if (_squads[index].IsEmpty)
|
||||
{
|
||||
return Retcode.RetLineupIsEmpty;
|
||||
}
|
||||
|
||||
CurrentIndex = index;
|
||||
return Retcode.RetSucc;
|
||||
}
|
||||
|
||||
public Lineup? Extra(ExtraLineupType type) => _extra.GetValueOrDefault(type);
|
||||
|
||||
public Lineup SetExtra(ExtraLineupType type, IEnumerable<uint> avatarIds, bool activate = true)
|
||||
{
|
||||
var lineup = new Lineup { Name = string.Empty };
|
||||
lineup.Fill(avatarIds);
|
||||
_extra[type] = lineup;
|
||||
|
||||
if (activate)
|
||||
{
|
||||
ExtraType = type;
|
||||
}
|
||||
|
||||
return lineup;
|
||||
}
|
||||
|
||||
public bool ActivateExtra(ExtraLineupType type)
|
||||
{
|
||||
if (type == ExtraLineupType.LineupNone)
|
||||
{
|
||||
ExtraType = ExtraLineupType.LineupNone;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_extra.TryGetValue(type, out var lineup) || lineup.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ExtraType = type;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ClearExtra()
|
||||
{
|
||||
_extra.Clear();
|
||||
ExtraType = ExtraLineupType.LineupNone;
|
||||
}
|
||||
|
||||
public void RefillMp() => _mp = MaxMp;
|
||||
|
||||
public bool SpendMp(uint count = 1)
|
||||
{
|
||||
if (Mp < count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_mp = Mp - count;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void GainMp(uint count) => _mp = Math.Min(Mp + count, MaxMp);
|
||||
|
||||
// the first squad gets the roster's first four; the rest start empty like a fresh account
|
||||
public void Seed(IEnumerable<uint> avatarIds)
|
||||
{
|
||||
if (_squads.All(s => s.IsEmpty))
|
||||
{
|
||||
_squads[0].Fill(avatarIds);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Prune(Func<uint, bool> owned)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
foreach (var squad in _squads.Concat(_extra.Values))
|
||||
{
|
||||
changed |= squad.Prune(owned);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// everything the account remembers between packets. srtools owns the roster and gear;
|
||||
// this is the rest: profile, teams, where the player stands, how the fights went.
|
||||
public sealed class Player(GameServerConfig config)
|
||||
{
|
||||
public const uint FullHp = 10_000;
|
||||
|
||||
public uint Uid { get; } = config.Uid;
|
||||
|
||||
public string Nickname { get; set; } = config.Nickname;
|
||||
|
||||
public string Signature { get; set; } = config.Signature;
|
||||
|
||||
public uint Level { get; set; } = config.Level;
|
||||
|
||||
public uint WorldLevel { get; set; } = config.WorldLevel;
|
||||
|
||||
public uint Stamina { get; set; } = 240;
|
||||
|
||||
public uint HeadIcon { get; set; } = 201001;
|
||||
|
||||
public Gender Gender { get; set; } = Gender.Woman;
|
||||
|
||||
public LanguageType Language { get; set; } = LanguageType.LanguageEn;
|
||||
|
||||
public bool LoggedIn { get; set; }
|
||||
|
||||
public uint EntryId { get; set; } = config.StartEntryId;
|
||||
|
||||
public uint TeleportId { get; set; } = config.StartTeleportId;
|
||||
|
||||
// last position the client reported; null until it moves
|
||||
public PlayerPosition? Position { get; set; }
|
||||
|
||||
public uint TrailblazerPath { get; set; } = 8002;
|
||||
|
||||
public uint MarchPath { get; set; } = 1001;
|
||||
|
||||
public LineupBook Lineups { get; } = new();
|
||||
|
||||
public Dictionary<uint, AvatarState> AvatarStates { get; } = [];
|
||||
|
||||
// avatars switched to their enhanced kit from the character screen
|
||||
public HashSet<uint> EnhancedAvatars { get; } = [];
|
||||
|
||||
public Dictionary<uint, ChallengeRecord> ChallengeRecords { get; } = [];
|
||||
|
||||
// keyed by peak id * 2 + (hard ? 1 : 0)
|
||||
public Dictionary<uint, PeakRecord> PeakRecords { get; } = [];
|
||||
|
||||
// the mob-stage teams picked on the AA screen, by peak id
|
||||
public Dictionary<uint, List<uint>> PeakLineups { get; } = [];
|
||||
|
||||
public Dictionary<uint, byte[]> ServerPrefs { get; } = [];
|
||||
|
||||
public HashSet<uint> FinishedColleges { get; } = [];
|
||||
|
||||
// the characters shown on the profile card, in order
|
||||
public List<uint> DisplayAvatars { get; } = [];
|
||||
|
||||
// set by /stage; the next fight plays it instead of whatever was hit
|
||||
public uint NextStageOverride { get; set; }
|
||||
|
||||
public bool Dirty { get; private set; }
|
||||
|
||||
public void Touch() => Dirty = true;
|
||||
|
||||
public void ClearDirty() => Dirty = false;
|
||||
|
||||
public AvatarState StateOf(uint avatarId, SrToolsData srtools)
|
||||
{
|
||||
if (!AvatarStates.TryGetValue(avatarId, out var state))
|
||||
{
|
||||
state = AvatarState.From(srtools.Avatars.GetValueOrDefault(avatarId));
|
||||
AvatarStates[avatarId] = state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public void HealAll(SrToolsData srtools)
|
||||
{
|
||||
foreach (var state in AvatarStates.Values)
|
||||
{
|
||||
state.Hp = FullHp;
|
||||
}
|
||||
|
||||
foreach (var avatarId in Lineups.Current.AvatarIds)
|
||||
{
|
||||
StateOf(avatarId, srtools).Hp = FullHp;
|
||||
}
|
||||
}
|
||||
|
||||
// challenges start every member at full health and half energy
|
||||
public void PrepareForChallenge(IEnumerable<uint> avatarIds, SrToolsData srtools)
|
||||
{
|
||||
foreach (var avatarId in avatarIds)
|
||||
{
|
||||
var state = StateOf(avatarId, srtools);
|
||||
state.Hp = FullHp;
|
||||
state.SpCur = state.SpMax / 2;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEnhanced(uint avatarId, SrToolsData srtools)
|
||||
{
|
||||
if (EnhancedAvatars.Contains(avatarId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return srtools.Avatars.GetValueOrDefault(avatarId)?.EnhancedId is > 0;
|
||||
}
|
||||
|
||||
public PeakRecord? PeakRecord(uint peakId, bool hard) => PeakRecords.GetValueOrDefault((peakId * 2) + (hard ? 1u : 0u));
|
||||
|
||||
public void SetPeakRecord(uint peakId, bool hard, PeakRecord record)
|
||||
{
|
||||
PeakRecords[(peakId * 2) + (hard ? 1u : 0u)] = record;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AvatarState
|
||||
{
|
||||
public uint Hp { get; set; } = Player.FullHp;
|
||||
|
||||
// energy as (current, max) in the avatar's own units
|
||||
public uint SpCur { get; set; } = 10_000;
|
||||
|
||||
public uint SpMax { get; set; } = 10_000;
|
||||
|
||||
public static AvatarState From(SrAvatar? configured) => new()
|
||||
{
|
||||
SpCur = configured?.SpValue ?? 10_000,
|
||||
SpMax = configured?.SpMax ?? 10_000,
|
||||
};
|
||||
|
||||
public SpBarInfo SpBar => new() { CurSp = SpCur, MaxSp = SpMax };
|
||||
}
|
||||
|
||||
public sealed class PlayerPosition
|
||||
{
|
||||
public int X { get; set; }
|
||||
|
||||
public int Y { get; set; }
|
||||
|
||||
public int Z { get; set; }
|
||||
|
||||
public int RotY { get; set; }
|
||||
|
||||
public MotionInfo ToMotion() => new()
|
||||
{
|
||||
Pos = new Protocol.Vector { X = X, Y = Y, Z = Z },
|
||||
Rot = new Protocol.Vector { Y = RotY },
|
||||
};
|
||||
|
||||
public static PlayerPosition From(MotionInfo motion) => new()
|
||||
{
|
||||
X = motion.Pos?.X ?? 0,
|
||||
Y = motion.Pos?.Y ?? 0,
|
||||
Z = motion.Pos?.Z ?? 0,
|
||||
RotY = motion.Rot?.Y ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ChallengeRecord
|
||||
{
|
||||
public uint Stars { get; set; }
|
||||
|
||||
public uint Score { get; set; }
|
||||
|
||||
public uint ScoreTwo { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PeakRecord
|
||||
{
|
||||
public uint Stars { get; set; }
|
||||
|
||||
public uint Cycles { get; set; }
|
||||
|
||||
public uint BuffId { get; set; }
|
||||
|
||||
public List<uint> FinishedTargets { get; set; } = [];
|
||||
|
||||
public List<uint> Avatars { get; set; } = [];
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// data/player.json. small, human readable, written whole; srtools keeps the roster elsewhere.
|
||||
public static class PlayerStore
|
||||
{
|
||||
public const string FileName = "player.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static bool TryLoad(string path, Player player)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerSave? save;
|
||||
|
||||
try
|
||||
{
|
||||
save = JsonSerializer.Deserialize<PlayerSave>(File.ReadAllText(path), Options);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (save is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Apply(save, player);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Save(string path, Player player)
|
||||
{
|
||||
var save = Capture(player);
|
||||
var json = JsonSerializer.Serialize(save, Options);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
// write-then-move so a crash mid-write never leaves a torn file
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
|
||||
private static PlayerSave Capture(Player player)
|
||||
{
|
||||
var save = new PlayerSave
|
||||
{
|
||||
Nickname = player.Nickname,
|
||||
Signature = player.Signature,
|
||||
WorldLevel = player.WorldLevel,
|
||||
HeadIcon = player.HeadIcon,
|
||||
Gender = (int)player.Gender,
|
||||
EntryId = player.EntryId,
|
||||
TeleportId = player.TeleportId,
|
||||
Position = player.Position,
|
||||
TrailblazerPath = player.TrailblazerPath,
|
||||
MarchPath = player.MarchPath,
|
||||
CurrentLineup = player.Lineups.CurrentIndex,
|
||||
Mp = player.Lineups.Mp,
|
||||
EnhancedAvatars = [.. player.EnhancedAvatars.Order()],
|
||||
FinishedColleges = [.. player.FinishedColleges.Order()],
|
||||
DisplayAvatars = [.. player.DisplayAvatars],
|
||||
};
|
||||
|
||||
foreach (var (id, state) in player.AvatarStates)
|
||||
{
|
||||
save.AvatarStates[id.ToString()] = new SavedAvatarState { Hp = state.Hp, SpCur = state.SpCur, SpMax = state.SpMax };
|
||||
}
|
||||
|
||||
foreach (var squad in player.Lineups.Squads)
|
||||
{
|
||||
save.Lineups.Add(new SavedLineup
|
||||
{
|
||||
Name = squad.Name,
|
||||
Slots = [.. squad.Slots],
|
||||
Leader = squad.LeaderAvatarId,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var (id, record) in player.ChallengeRecords)
|
||||
{
|
||||
save.ChallengeRecords[id.ToString()] = record;
|
||||
}
|
||||
|
||||
foreach (var (key, record) in player.PeakRecords)
|
||||
{
|
||||
save.PeakRecords[key.ToString()] = record;
|
||||
}
|
||||
|
||||
foreach (var (peakId, avatars) in player.PeakLineups)
|
||||
{
|
||||
save.PeakLineups[peakId.ToString()] = avatars;
|
||||
}
|
||||
|
||||
foreach (var (id, data) in player.ServerPrefs)
|
||||
{
|
||||
save.ServerPrefs[id.ToString()] = Convert.ToBase64String(data);
|
||||
}
|
||||
|
||||
return save;
|
||||
}
|
||||
|
||||
private static void Apply(PlayerSave save, Player player)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(save.Nickname))
|
||||
{
|
||||
player.Nickname = save.Nickname;
|
||||
}
|
||||
|
||||
player.Signature = save.Signature ?? player.Signature;
|
||||
player.WorldLevel = save.WorldLevel is { } worldLevel and <= 6 ? worldLevel : player.WorldLevel;
|
||||
player.HeadIcon = save.HeadIcon == 0 ? player.HeadIcon : save.HeadIcon;
|
||||
player.Gender = save.Gender is 1 or 2 ? (Gender)save.Gender : player.Gender;
|
||||
player.EntryId = save.EntryId == 0 ? player.EntryId : save.EntryId;
|
||||
player.TeleportId = save.TeleportId;
|
||||
player.Position = save.Position;
|
||||
player.TrailblazerPath = save.TrailblazerPath == 0 ? player.TrailblazerPath : save.TrailblazerPath;
|
||||
player.MarchPath = save.MarchPath == 0 ? player.MarchPath : save.MarchPath;
|
||||
|
||||
for (var i = 0; i < save.Lineups.Count && i < LineupBook.SquadCount; i++)
|
||||
{
|
||||
var saved = save.Lineups[i];
|
||||
var squad = player.Lineups.Squad((uint)i);
|
||||
|
||||
squad.Replace(saved.Slots.Select((id, slot) => ((uint)slot, id)), 0);
|
||||
squad.SetLeaderAvatar(saved.Leader);
|
||||
|
||||
if (!string.IsNullOrEmpty(saved.Name))
|
||||
{
|
||||
squad.Name = saved.Name;
|
||||
}
|
||||
}
|
||||
|
||||
if (player.Lineups.SetCurrent(save.CurrentLineup) != Retcode.RetSucc)
|
||||
{
|
||||
player.Lineups.SetCurrent(0);
|
||||
}
|
||||
|
||||
player.Lineups.Mp = save.Mp;
|
||||
|
||||
foreach (var id in save.EnhancedAvatars)
|
||||
{
|
||||
player.EnhancedAvatars.Add(id);
|
||||
}
|
||||
|
||||
foreach (var id in save.FinishedColleges)
|
||||
{
|
||||
player.FinishedColleges.Add(id);
|
||||
}
|
||||
|
||||
player.DisplayAvatars.AddRange(save.DisplayAvatars.Where(id => id != 0).Distinct().Take(4));
|
||||
|
||||
foreach (var (key, state) in save.AvatarStates)
|
||||
{
|
||||
if (uint.TryParse(key, out var id) && state.SpMax > 0)
|
||||
{
|
||||
player.AvatarStates[id] = new AvatarState
|
||||
{
|
||||
Hp = Math.Min(state.Hp, Player.FullHp),
|
||||
SpCur = Math.Min(state.SpCur, state.SpMax),
|
||||
SpMax = state.SpMax,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, record) in save.ChallengeRecords)
|
||||
{
|
||||
if (uint.TryParse(key, out var id))
|
||||
{
|
||||
player.ChallengeRecords[id] = record;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, record) in save.PeakRecords)
|
||||
{
|
||||
if (uint.TryParse(key, out var id))
|
||||
{
|
||||
player.PeakRecords[id] = record;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, avatars) in save.PeakLineups)
|
||||
{
|
||||
if (uint.TryParse(key, out var id))
|
||||
{
|
||||
player.PeakLineups[id] = avatars;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, data) in save.ServerPrefs)
|
||||
{
|
||||
if (uint.TryParse(key, out var id))
|
||||
{
|
||||
try
|
||||
{
|
||||
player.ServerPrefs[id] = Convert.FromBase64String(data);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// a hand-edited entry; the client will just resend it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayerSave
|
||||
{
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
public string? Signature { get; set; }
|
||||
|
||||
public uint? WorldLevel { get; set; }
|
||||
|
||||
public uint HeadIcon { get; set; }
|
||||
|
||||
public int Gender { get; set; }
|
||||
|
||||
public uint EntryId { get; set; }
|
||||
|
||||
public uint TeleportId { get; set; }
|
||||
|
||||
public PlayerPosition? Position { get; set; }
|
||||
|
||||
public uint TrailblazerPath { get; set; }
|
||||
|
||||
public uint MarchPath { get; set; }
|
||||
|
||||
public uint CurrentLineup { get; set; }
|
||||
|
||||
public uint Mp { get; set; } = LineupBook.BaseMaxMp;
|
||||
|
||||
public List<SavedLineup> Lineups { get; set; } = [];
|
||||
|
||||
public List<uint> EnhancedAvatars { get; set; } = [];
|
||||
|
||||
public List<uint> FinishedColleges { get; set; } = [];
|
||||
|
||||
public List<uint> DisplayAvatars { get; set; } = [];
|
||||
|
||||
public Dictionary<string, SavedAvatarState> AvatarStates { get; set; } = [];
|
||||
|
||||
public Dictionary<string, ChallengeRecord> ChallengeRecords { get; set; } = [];
|
||||
|
||||
public Dictionary<string, PeakRecord> PeakRecords { get; set; } = [];
|
||||
|
||||
public Dictionary<string, List<uint>> PeakLineups { get; set; } = [];
|
||||
|
||||
public Dictionary<string, string> ServerPrefs { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SavedAvatarState
|
||||
{
|
||||
public uint Hp { get; set; } = Player.FullHp;
|
||||
|
||||
public uint SpCur { get; set; }
|
||||
|
||||
public uint SpMax { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SavedLineup
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public List<uint> Slots { get; set; } = [];
|
||||
|
||||
public uint Leader { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Game;
|
||||
|
||||
// what the account owns. srtools' roster when one has been uploaded, otherwise everything
|
||||
// playable so the game is usable before the tool has ever been pointed at us.
|
||||
public static class Roster
|
||||
{
|
||||
public static bool IsPlayable(uint avatarId) => avatarId is (> 1000 and < 2000) or (>= 8001 and <= 8010);
|
||||
|
||||
public static IReadOnlyList<uint> OwnedIds(GameWorld world) => world.OwnedAvatarIds;
|
||||
|
||||
public static bool IsOwned(GameWorld world, uint avatarId) => world.OwnedAvatarIds.Contains(avatarId);
|
||||
|
||||
// resolves a base id (8001, 1001) to the path the player currently walks
|
||||
public static uint ResolvePath(GameWorld world, Player player, uint avatarId)
|
||||
{
|
||||
var baseId = world.Data.BaseAvatarId(avatarId);
|
||||
|
||||
if (baseId == avatarId && world.Data.MultiPathAvatarIds.Contains(avatarId))
|
||||
{
|
||||
var variants = world.OwnedAvatarIds.Where(id => world.Data.BaseAvatarId(id) == baseId).ToList();
|
||||
return variants.Count > 0 ? SelectedPath(baseId, variants, player) : avatarId;
|
||||
}
|
||||
|
||||
return avatarId;
|
||||
}
|
||||
|
||||
public static List<Avatar> BuildAvatars(GameWorld world, Player player)
|
||||
{
|
||||
var owned = OwnedIds(world);
|
||||
var avatars = new List<Avatar>();
|
||||
|
||||
// the client wants one Avatar per *base* id; paths ride along in AvatarPathData
|
||||
foreach (var group in owned.GroupBy(world.Data.BaseAvatarId).OrderBy(g => g.Key))
|
||||
{
|
||||
// gear hangs off the selected path (8002), not the base id (8001)
|
||||
var path = SelectedPath(group.Key, group, player);
|
||||
var configured = world.Player.Avatars.GetValueOrDefault(path)
|
||||
?? world.Player.Avatars.GetValueOrDefault(group.First());
|
||||
|
||||
avatars.Add(new Avatar
|
||||
{
|
||||
BaseAvatarId = group.Key,
|
||||
Level = configured?.Level ?? 80,
|
||||
Promotion = configured?.Promotion ?? 6,
|
||||
Exp = 0,
|
||||
FirstMetTimeStamp = 1712924677,
|
||||
CurMultiPathAvatarType = path,
|
||||
EquipmentUniqueId = world.Player.LightconeFor(path)?.UniqueId ?? 0,
|
||||
HasTakenPromotionRewardList = { Enumerable.Range(0, (int)(configured?.Promotion ?? 6)).Select(i => (uint)i) },
|
||||
});
|
||||
}
|
||||
|
||||
return avatars;
|
||||
}
|
||||
|
||||
public static List<AvatarPathData> BuildPathData(GameWorld world, Player player)
|
||||
{
|
||||
var paths = new List<AvatarPathData>();
|
||||
|
||||
foreach (var avatarId in OwnedIds(world))
|
||||
{
|
||||
var configured = world.Player.Avatars.GetValueOrDefault(avatarId);
|
||||
|
||||
var path = new AvatarPathData
|
||||
{
|
||||
AvatarId = avatarId,
|
||||
Rank = configured?.Data.Rank ?? 6,
|
||||
PathEquipmentId = world.Player.LightconeFor(avatarId)?.UniqueId ?? 0,
|
||||
UnkEnhancedId = player.IsEnhanced(avatarId, world.Player) ? EnhancedId(world.Data, avatarId) : 0,
|
||||
};
|
||||
|
||||
// point_id here is the anchor index; srtools stores exactly that
|
||||
var tree = configured is { Data.SkillsByAnchorType.Count: > 0 }
|
||||
? configured.Data.SkillsByAnchorType.Select(kv => (kv.Key, kv.Value))
|
||||
: world.Data.MaxedPathSkillTree(avatarId);
|
||||
|
||||
foreach (var (anchor, level) in tree)
|
||||
{
|
||||
path.AvatarPathSkillTree.Add(new AvatarPathSkillTree { PointId = anchor, Level = level });
|
||||
}
|
||||
|
||||
foreach (var relic in world.Player.RelicsFor(avatarId))
|
||||
{
|
||||
path.EquipRelicList.Add(new EquipRelic { Type = relic.Slot, RelicUniqueId = relic.UniqueId });
|
||||
}
|
||||
|
||||
paths.Add(path);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
public static uint EnhancedId(GameData data, uint avatarId) =>
|
||||
data.EnhancedAvatars.TryGetValue(avatarId, out var id) ? id : 1;
|
||||
|
||||
public static uint SelectedPath(uint baseId, IEnumerable<uint> variants, Player player)
|
||||
{
|
||||
var list = variants.ToList();
|
||||
|
||||
return baseId switch
|
||||
{
|
||||
8001 => list.Contains(player.TrailblazerPath) ? player.TrailblazerPath : list.Max(),
|
||||
1001 => list.Contains(player.MarchPath) ? player.MarchPath : list.Min(),
|
||||
_ => baseId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class AvatarHandlers
|
||||
{
|
||||
public static Task OnGetAvatarData(PlayerSession session, GetAvatarDataCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
|
||||
var response = new GetAvatarDataScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
IsGetAll = request.IsGetAll,
|
||||
};
|
||||
|
||||
response.AvatarList.AddRange(Roster.BuildAvatars(world, session.Player));
|
||||
|
||||
var paths = Roster.BuildPathData(world, session.Player);
|
||||
response.AvatarPathDataInfoList.AddRange(paths);
|
||||
|
||||
// the client needs to know which of those ids are multipath avatars
|
||||
response.BasicTypeIdList.AddRange(
|
||||
paths.Select(p => p.AvatarId).Where(world.Data.MultiPathAvatarIds.Contains).Distinct());
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"roster: {Avatars} avatars, {Paths} paths ({Source})",
|
||||
response.AvatarList.Count, response.AvatarPathDataInfoList.Count,
|
||||
world.HasSrToolsRoster ? "srtools" : "all playable");
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static async Task OnSetAvatarPath(PlayerSession session, SetAvatarPathCsReq request)
|
||||
{
|
||||
var avatarId = (uint)request.AvatarId;
|
||||
var baseId = session.World.Data.BaseAvatarId(avatarId);
|
||||
|
||||
if (!Roster.IsOwned(session.World, avatarId))
|
||||
{
|
||||
await session.SendAsync(new SetAvatarPathScRsp { Retcode = (uint)Retcode.RetAvatarNotExist, AvatarId = request.AvatarId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseId == 8001)
|
||||
{
|
||||
session.Player.TrailblazerPath = avatarId;
|
||||
session.Player.Gender = avatarId % 2 == 1 ? Gender.Man : Gender.Woman;
|
||||
}
|
||||
else
|
||||
{
|
||||
session.Player.MarchPath = avatarId;
|
||||
}
|
||||
|
||||
session.Player.Touch();
|
||||
session.Logger.LogInformation("path {Base} -> {Path}", baseId, avatarId);
|
||||
|
||||
await session.SendAsync(new AvatarPathChangedNotify
|
||||
{
|
||||
BaseAvatarId = baseId,
|
||||
CurMultiPathAvatarType = request.AvatarId,
|
||||
});
|
||||
|
||||
await session.SyncAvatarsAsync();
|
||||
await session.SyncLineupAsync();
|
||||
await session.SendAsync(new SetAvatarPathScRsp { Retcode = 0, AvatarId = request.AvatarId });
|
||||
}
|
||||
|
||||
// the enhanced kit is a per-avatar toggle on the character screen
|
||||
public static async Task OnSetAvatarEnhancedId(PlayerSession session, SetAvatarEnhancedIdCsReq request)
|
||||
{
|
||||
var player = session.Player;
|
||||
|
||||
if (request.EnhancedId == 0)
|
||||
{
|
||||
player.EnhancedAvatars.Remove(request.AvatarId);
|
||||
}
|
||||
else if (session.World.Data.EnhancedAvatars.ContainsKey(request.AvatarId))
|
||||
{
|
||||
player.EnhancedAvatars.Add(request.AvatarId);
|
||||
}
|
||||
|
||||
player.Touch();
|
||||
await session.SyncAvatarsAsync();
|
||||
|
||||
await session.SendAsync(new SetAvatarEnhancedIdScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
GrowthAvatarId = request.AvatarId,
|
||||
UnkEnhancedId = request.EnhancedId,
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task OnSetGender(PlayerSession session, SetGenderCsReq request)
|
||||
{
|
||||
var player = session.Player;
|
||||
var world = session.World;
|
||||
|
||||
if (request.Gender is Gender.Man or Gender.Woman)
|
||||
{
|
||||
player.Gender = request.Gender;
|
||||
|
||||
// keep the trailblazer on a path of the chosen body
|
||||
var wantOdd = request.Gender == Gender.Man;
|
||||
var variants = world.OwnedAvatarIds.Where(id => id is >= 8001 and <= 8010 && (id % 2 == 1) == wantOdd).ToList();
|
||||
|
||||
if (variants.Count > 0 && !variants.Contains(player.TrailblazerPath))
|
||||
{
|
||||
player.TrailblazerPath = variants.Max();
|
||||
}
|
||||
|
||||
player.Touch();
|
||||
await session.SyncAvatarsAsync();
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
await session.SendAsync(new SetGenderScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
CurAvatarPath = (MultiPathAvatarType)player.TrailblazerPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class BattleHandlers
|
||||
{
|
||||
public static Task OnStartCocoonStage(PlayerSession session, StartCocoonStageCsReq request)
|
||||
{
|
||||
var battle = session.Battles.StartCocoon(request.CocoonId, request.Wave, request.WorldLevel);
|
||||
|
||||
return session.SendAsync(new StartCocoonStageScRsp
|
||||
{
|
||||
Retcode = battle is null ? (uint)Retcode.RetStageConfigNotExist : 0,
|
||||
CocoonId = request.CocoonId,
|
||||
PropEntityId = request.PropEntityId,
|
||||
Wave = battle?.Wave ?? request.Wave,
|
||||
BattleInfo = battle?.Info,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnQuickStartCocoonStage(PlayerSession session, QuickStartCocoonStageCsReq request)
|
||||
{
|
||||
var battle = session.Battles.StartCocoon(request.CocoonId, request.Wave, request.WorldLevel);
|
||||
|
||||
if (battle is not null && request.WorldLevel > 0)
|
||||
{
|
||||
battle.Info.WorldLevel = request.WorldLevel;
|
||||
}
|
||||
|
||||
return session.SendAsync(new QuickStartCocoonStageScRsp
|
||||
{
|
||||
Retcode = battle is null ? (uint)Retcode.RetStageConfigNotExist : 0,
|
||||
CocoonId = request.CocoonId,
|
||||
Wave = battle?.Wave ?? request.Wave,
|
||||
BattleInfo = battle?.Info,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnQuickStartFarmElement(PlayerSession session, QuickStartFarmElementCsReq request)
|
||||
{
|
||||
var battle = session.Battles.StartFarmElement(request.PAOFHFLFFHD, request.WorldLevel);
|
||||
|
||||
return session.SendAsync(new QuickStartFarmElementScRsp
|
||||
{
|
||||
Retcode = battle is null ? (uint)Retcode.RetStageConfigNotExist : 0,
|
||||
PAOFHFLFFHD = request.PAOFHFLFFHD,
|
||||
WorldLevel = request.WorldLevel,
|
||||
BattleInfo = battle?.Info,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnStartBattleCollege(PlayerSession session, StartBattleCollegeCsReq request)
|
||||
{
|
||||
var battle = session.Battles.StartCollege(request.Id);
|
||||
|
||||
return session.SendAsync(new StartBattleCollegeScRsp
|
||||
{
|
||||
Retcode = battle is null ? (uint)Retcode.RetStageConfigNotExist : 0,
|
||||
Id = request.Id,
|
||||
BattleInfo = battle?.Info,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnGetBattleCollegeData(PlayerSession session, GetBattleCollegeDataCsReq request)
|
||||
{
|
||||
var response = new GetBattleCollegeDataScRsp { Retcode = 0 };
|
||||
response.FinishedCollegeIdList.AddRange(session.Player.FinishedColleges.Order());
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnSceneEnterStage(PlayerSession session, SceneEnterStageCsReq request)
|
||||
{
|
||||
var battle = session.Battles.StartEvent(request.EventId);
|
||||
|
||||
return session.SendAsync(new SceneEnterStageScRsp
|
||||
{
|
||||
Retcode = battle is null ? (uint)Retcode.RetStageConfigNotExist : 0,
|
||||
BattleInfo = battle?.Info,
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task OnSceneCastSkill(PlayerSession session, SceneCastSkillCsReq request)
|
||||
{
|
||||
var outcome = session.Battles.HandleCast(request);
|
||||
|
||||
if (outcome.RemovedEntities.Count > 0)
|
||||
{
|
||||
await session.RemoveEntitiesAsync(outcome.RemovedEntities);
|
||||
}
|
||||
|
||||
// a smashed orb pays out two points or a chunk of health for the whole team
|
||||
if (outcome.MpOrbs > 0)
|
||||
{
|
||||
session.Player.Lineups.GainMp((uint)(2 * outcome.MpOrbs));
|
||||
session.Player.Touch();
|
||||
await session.SyncLineupAsync(SyncLineupReason.SyncReasonMpAddPropHit);
|
||||
}
|
||||
|
||||
if (outcome.HpOrbs > 0)
|
||||
{
|
||||
session.HealParty((uint)(2000 * outcome.HpOrbs));
|
||||
await session.SyncLineupAsync(SyncLineupReason.SyncReasonHpAddPropHit);
|
||||
}
|
||||
|
||||
var response = new SceneCastSkillScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
CastEntityId = request.CastEntityId,
|
||||
BattleInfo = outcome.Battle,
|
||||
};
|
||||
|
||||
response.MonsterBattleInfo.AddRange(outcome.Hits);
|
||||
await session.SendAsync(response);
|
||||
}
|
||||
|
||||
// a technique costs one point; the client shows whatever we say is left
|
||||
public static async Task OnSceneCastSkillCostMp(PlayerSession session, SceneCastSkillCostMpCsReq request)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
book.SpendMp();
|
||||
session.Player.Touch();
|
||||
|
||||
await session.SendAsync(new SceneCastSkillMpUpdateScNotify
|
||||
{
|
||||
CastEntityId = request.CastEntityId,
|
||||
Mp = book.Mp,
|
||||
});
|
||||
|
||||
await session.SendAsync(new SceneCastSkillCostMpScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
CastEntityId = request.CastEntityId,
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task OnPveBattleResult(PlayerSession session, PVEBattleResultCsReq request)
|
||||
{
|
||||
var response = await session.Battles.FinishAsync(request);
|
||||
await session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnQuitBattle(PlayerSession session, QuitBattleCsReq request)
|
||||
{
|
||||
session.Battles.Quit();
|
||||
return session.SendAsync(new QuitBattleScRsp { Retcode = 0 });
|
||||
}
|
||||
|
||||
public static Task OnGetCurBattleInfo(PlayerSession session, GetCurBattleInfoCsReq request) =>
|
||||
session.SendAsync(new GetCurBattleInfoScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
BattleInfo = session.Battles.Current?.Info ?? new SceneBattleInfo(),
|
||||
LastEndStatus = session.Battles.LastEndStatus,
|
||||
});
|
||||
|
||||
public static Task OnSyncClientResVersion(PlayerSession session, SyncClientResVersionCsReq request) =>
|
||||
session.SendAsync(new SyncClientResVersionScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
ClientResVersion = request.ClientResVersion,
|
||||
});
|
||||
|
||||
// the client sends this whenever it walks out of range of a farm prop
|
||||
public static Task OnDeactivateFarmElement(PlayerSession session, DeactivateFarmElementCsReq request) =>
|
||||
session.SendAsync(new DeactivateFarmElementScRsp { Retcode = 0, EntityId = request.EntityId });
|
||||
|
||||
public static Task OnGetFarmStageGachaInfo(PlayerSession session, GetFarmStageGachaInfoCsReq request)
|
||||
{
|
||||
var response = new GetFarmStageGachaInfoScRsp { Retcode = 0 };
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
foreach (var gachaId in request.FarmStageGachaIdList)
|
||||
{
|
||||
response.FarmStageGachaInfoList.Add(new FarmStageGachaInfo
|
||||
{
|
||||
GachaId = gachaId,
|
||||
BeginTime = 0,
|
||||
EndTime = now + 3600,
|
||||
});
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.GameServer.Battle;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// what happens between the fights of a floor: node changes, scores, stars, settle screens
|
||||
public static class ChallengeFlow
|
||||
{
|
||||
public static async Task OnBattleEndAsync(PlayerSession session, BattleInstance battle, PVEBattleResultCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (state.IsPeak)
|
||||
{
|
||||
await OnPeakBattleEndAsync(session, request);
|
||||
return;
|
||||
}
|
||||
|
||||
RecordScore(state, request.Stt);
|
||||
|
||||
switch (request.EndStatus)
|
||||
{
|
||||
case BattleEndStatus.BattleEndWin:
|
||||
if (state.Kind == ChallengeKind.MemoryOfChaos)
|
||||
{
|
||||
state.DeadAvatars += (uint)state.Lineup.Count(id => session.Player.StateOf(
|
||||
Roster.ResolvePath(session.World, session.Player, id), session.World.Player).Hp == 0);
|
||||
|
||||
var used = request.Stt?.RoundCnt ?? 0;
|
||||
state.RoundsLeft = Math.Max(1, state.RoundsLeft - Math.Min(used, state.RoundsLeft));
|
||||
}
|
||||
|
||||
state.SavedMp = session.Player.Lineups.Mp;
|
||||
|
||||
if (!session.Scene.Monsters.Any())
|
||||
{
|
||||
await OnNodeClearedAsync(session, request);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case BattleEndStatus.BattleEndQuit:
|
||||
session.Player.Lineups.Mp = state.SavedMp;
|
||||
await ReturnToStartAsync(session);
|
||||
break;
|
||||
|
||||
default:
|
||||
// PF and AS end on the turn limit by design; that is a finished half, not a loss
|
||||
if (state.Kind != ChallengeKind.MemoryOfChaos && request.Stt?.EndReason == BattleEndReason.TurnLimit)
|
||||
{
|
||||
await OnNodeClearedAsync(session, request);
|
||||
}
|
||||
else
|
||||
{
|
||||
state.Fail();
|
||||
await SendSettleAsync(session, isWin: false);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordScore(ChallengeState state, BattleStatistics? stats)
|
||||
{
|
||||
if (stats is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.Kind)
|
||||
{
|
||||
case ChallengeKind.PureFiction:
|
||||
// the client reports the running total; keep this half's share
|
||||
var previous = state.TotalScore - state.Scores[state.Node];
|
||||
state.Scores[state.Node] = stats.ChallengeScore >= previous ? stats.ChallengeScore - previous : stats.ChallengeScore;
|
||||
break;
|
||||
|
||||
case ChallengeKind.ApocalypticShadow:
|
||||
if (stats.BattleTargetInfo.TryGetValue(1, out var gauge))
|
||||
{
|
||||
state.Scores[state.Node] = (uint)gauge.BattleTargetList_.Sum(t => (long)t.Progress);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task OnNodeClearedAsync(PlayerSession session, PVEBattleResultCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (state.Kind == ChallengeKind.ApocalypticShadow)
|
||||
{
|
||||
await SendBossPhaseSettleAsync(session, request);
|
||||
|
||||
if (!state.IsLastNode)
|
||||
{
|
||||
// the client asks for the next phase itself
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.IsLastNode)
|
||||
{
|
||||
await AdvanceNodeAsync(session);
|
||||
return;
|
||||
}
|
||||
|
||||
await FinishAsync(session);
|
||||
}
|
||||
|
||||
public static async Task AdvanceNodeAsync(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (!state.SwitchNode(session.World.Data, state.Node + 1))
|
||||
{
|
||||
session.Logger.LogWarning("challenge {Id} has no second node; settling", state.ChallengeId);
|
||||
await FinishAsync(session);
|
||||
return;
|
||||
}
|
||||
|
||||
await ActivateNodeAsync(session);
|
||||
await session.SendAsync(new ChallengeLineupNotify { ExtraLineupType = state.LineupType });
|
||||
await session.EnterChallengeSceneAsync();
|
||||
await session.SyncLineupAsync();
|
||||
|
||||
session.Logger.LogInformation("challenge {Id}: node {Node} at stage {Stage}", state.ChallengeId, state.Node + 1, state.StageId);
|
||||
}
|
||||
|
||||
// full health, half energy, a full technique bar: the way every node opens
|
||||
public static Task ActivateNodeAsync(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
var book = session.Player.Lineups;
|
||||
|
||||
var lineup = book.Extra(state.LineupType) ?? book.SetExtra(state.LineupType, state.Lineup, activate: false);
|
||||
|
||||
if (lineup.IsEmpty)
|
||||
{
|
||||
lineup.Fill(state.Lineup);
|
||||
}
|
||||
|
||||
book.ActivateExtra(state.LineupType);
|
||||
book.RefillMp();
|
||||
state.SavedMp = book.Mp;
|
||||
|
||||
session.Player.PrepareForChallenge(
|
||||
lineup.AvatarIds.Select(id => Roster.ResolvePath(session.World, session.Player, id)), session.World.Player);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static async Task FinishAsync(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
var stars = state.CalculateStars(session.World.Data);
|
||||
state.Finish(stars);
|
||||
|
||||
var records = session.Player.ChallengeRecords;
|
||||
var record = records.GetValueOrDefault(state.ChallengeId) ?? new ChallengeRecord();
|
||||
|
||||
if (stars >= record.Stars)
|
||||
{
|
||||
record.Stars = stars;
|
||||
record.Score = Math.Max(record.Score, state.Scores[0]);
|
||||
record.ScoreTwo = Math.Max(record.ScoreTwo, state.Scores[1]);
|
||||
}
|
||||
|
||||
records[state.ChallengeId] = record;
|
||||
session.Player.Touch();
|
||||
|
||||
session.Logger.LogInformation("challenge {Id} cleared: {Stars} stars, score {Score}/{ScoreTwo}",
|
||||
state.ChallengeId, stars, state.Scores[0], state.Scores[1]);
|
||||
|
||||
await SendSettleAsync(session, isWin: true);
|
||||
}
|
||||
|
||||
private static Task SendSettleAsync(PlayerSession session, bool isWin)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
return session.SendAsync(new ChallengeSettleNotify
|
||||
{
|
||||
ChallengeId = state.ChallengeId,
|
||||
IsWin = isWin,
|
||||
Star = state.Stars,
|
||||
ChallengeScore = state.Scores[0],
|
||||
ScoreTwo = state.Scores[1],
|
||||
Reward = new ItemList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static Task SendBossPhaseSettleAsync(PlayerSession session, PVEBattleResultCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
var notify = new ChallengeBossPhaseSettleNotify
|
||||
{
|
||||
ChallengeId = state.ChallengeId,
|
||||
IsWin = true,
|
||||
IsSecondHalf = state.Node == 1,
|
||||
Phase = (uint)state.Node + 1,
|
||||
ChallengeScore = state.Scores[0],
|
||||
ScoreTwo = state.Scores[1],
|
||||
Star = state.Stars,
|
||||
IsReward = true,
|
||||
PageType = 1,
|
||||
};
|
||||
|
||||
if (request.Stt?.BattleTargetInfo.TryGetValue(1, out var targets) == true)
|
||||
{
|
||||
notify.BattleTargetList.AddRange(targets.BattleTargetList_);
|
||||
}
|
||||
|
||||
return session.SendAsync(notify);
|
||||
}
|
||||
|
||||
// the retry button: same half, fresh party
|
||||
public static async Task RestartNodeAsync(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
state.SwitchNode(session.World.Data, state.Node);
|
||||
|
||||
await ActivateNodeAsync(session);
|
||||
await session.EnterChallengeSceneAsync();
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
private static async Task ReturnToStartAsync(PlayerSession session)
|
||||
{
|
||||
var motion = session.World.Scenes.AnchorMotion(session.Challenge.EntryId);
|
||||
|
||||
if (motion is not null)
|
||||
{
|
||||
await session.MoveActorsAsync(motion);
|
||||
}
|
||||
}
|
||||
|
||||
public static CurChallenge BuildCurChallenge(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
var current = new CurChallenge
|
||||
{
|
||||
ChallengeId = state.ChallengeId,
|
||||
Status = state.Status == ChallengeStatus.ChallengeUnknown ? ChallengeStatus.ChallengeDoing : state.Status,
|
||||
RoundCount = state.Kind == ChallengeKind.MemoryOfChaos ? state.CycleCount - state.RoundsLeft : state.CycleCount,
|
||||
ScoreId = state.Scores[0],
|
||||
ScoreTwo = state.Scores[1],
|
||||
DeadAvatarNum = state.DeadAvatars,
|
||||
ExtraLineupType = state.LineupType,
|
||||
};
|
||||
|
||||
// PF and AS carry their active blessings back to the client
|
||||
switch (state.Kind)
|
||||
{
|
||||
case ChallengeKind.PureFiction:
|
||||
current.StageInfo = new ChallengeCurBuffInfo
|
||||
{
|
||||
CurStoryBuffs = new ChallengeStoryBuffList { BuffList = { state.SelectedBuffs.Where(id => id != 0) } },
|
||||
};
|
||||
break;
|
||||
|
||||
case ChallengeKind.ApocalypticShadow:
|
||||
current.StageInfo = new ChallengeCurBuffInfo
|
||||
{
|
||||
CurBossBuffs = new ChallengeBossBuffList
|
||||
{
|
||||
BuffList = { state.SelectedBuffs.Where(id => id != 0) },
|
||||
ChallengeBossConst = 1,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
current.StageInfo = new ChallengeCurBuffInfo();
|
||||
break;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public static ChallengeStageInfo? BuildStageInfo(PlayerSession session)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (state.Kind != ChallengeKind.ApocalypticShadow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var info = new ChallengeStageInfo
|
||||
{
|
||||
BossInfo = new ChallengeBossInfo
|
||||
{
|
||||
FirstNode = new ChallengeBossSingleNodeInfo { BuffId = state.SelectedBuffs[0], IsWin = state.Node > 0 },
|
||||
SecondNode = new ChallengeBossSingleNodeInfo { BuffId = state.SelectedBuffs[1] },
|
||||
Unk1 = true,
|
||||
},
|
||||
};
|
||||
|
||||
info.BossInfo.AvatarLineupFirst.AddRange(state.Lineups[0].Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType }));
|
||||
info.BossInfo.AvatarLineupSecond.AddRange(state.Lineups[1].Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType }));
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---- Anomaly Arbitration ----
|
||||
|
||||
private static async Task OnPeakBattleEndAsync(PlayerSession session, PVEBattleResultCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
switch (request.EndStatus)
|
||||
{
|
||||
case BattleEndStatus.BattleEndWin:
|
||||
state.SavedMp = session.Player.Lineups.Mp;
|
||||
|
||||
if (session.Scene.Monsters.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var finished = FinishedPeakTargets(session, request.Stt);
|
||||
var stars = state.HardMode && state.Boss is not null ? 3u : (uint)Math.Min(3, finished.Count);
|
||||
state.Finish(stars);
|
||||
|
||||
session.Player.SetPeakRecord(state.ChallengeId, state.HardMode && state.Boss is not null, new PeakRecord
|
||||
{
|
||||
Stars = stars,
|
||||
Cycles = request.Stt?.RoundCnt ?? 0,
|
||||
BuffId = state.SelectedBuffs[0],
|
||||
FinishedTargets = finished,
|
||||
Avatars = [.. state.Lineup],
|
||||
});
|
||||
|
||||
await session.SendAsync(new ChallengePeakSettleScNotify
|
||||
{
|
||||
PeakId = state.ChallengeId,
|
||||
IsWin = true,
|
||||
CyclesUsed = request.Stt?.RoundCnt ?? 0,
|
||||
FinishedTargetList = { finished },
|
||||
HardModeHasPassed = state.HardMode && state.Boss is not null,
|
||||
IsFirstPass = true,
|
||||
});
|
||||
|
||||
await SendPeakGroupUpdateAsync(session, state.ChallengeId);
|
||||
break;
|
||||
|
||||
case BattleEndStatus.BattleEndQuit:
|
||||
session.Player.Lineups.Mp = state.SavedMp;
|
||||
await ReturnToStartAsync(session);
|
||||
break;
|
||||
|
||||
default:
|
||||
state.Fail();
|
||||
|
||||
await session.SendAsync(new ChallengePeakSettleScNotify
|
||||
{
|
||||
PeakId = state.ChallengeId,
|
||||
IsWin = false,
|
||||
CyclesUsed = request.Stt?.RoundCnt ?? 0,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// a target is met when its counter stayed within the configured parameter
|
||||
private static List<uint> FinishedPeakTargets(PlayerSession session, BattleStatistics? stats)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
var finished = new List<uint>();
|
||||
|
||||
if (stats is null || !stats.BattleTargetInfo.TryGetValue(5, out var reported))
|
||||
{
|
||||
return state.PeakTargets.Count == 0 ? [] : finished;
|
||||
}
|
||||
|
||||
foreach (var targetId in state.PeakTargets)
|
||||
{
|
||||
var target = reported.BattleTargetList_.FirstOrDefault(t => t.Id == targetId);
|
||||
var param = session.World.Data.BattleTargets.GetValueOrDefault(targetId)?.TargetParam ?? 0;
|
||||
|
||||
if (target is not null && target.Progress <= param)
|
||||
{
|
||||
finished.Add(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
return finished;
|
||||
}
|
||||
|
||||
public static Task SendPeakGroupUpdateAsync(PlayerSession session, uint peakId)
|
||||
{
|
||||
var group = session.World.Data.ChallengePeakGroups.Values
|
||||
.FirstOrDefault(g => g.BossLevelID == peakId || g.PreLevelIDList.Contains(peakId));
|
||||
|
||||
return group is null
|
||||
? Task.CompletedTask
|
||||
: session.SendAsync(new ChallengePeakGroupDataUpdateScNotify { ChallengePeakGroup = BuildPeakGroup(session, group) });
|
||||
}
|
||||
|
||||
public static ChallengePeakGroup BuildPeakGroup(PlayerSession session, ChallengePeakGroupExcel group)
|
||||
{
|
||||
var data = session.World.Data;
|
||||
var player = session.Player;
|
||||
|
||||
var proto = new ChallengePeakGroup
|
||||
{
|
||||
PeakGroupId = group.ID,
|
||||
DisableHardMode = false,
|
||||
};
|
||||
|
||||
var stars = 0u;
|
||||
|
||||
foreach (var peakId in group.PreLevelIDList.Where(data.ChallengePeaks.ContainsKey))
|
||||
{
|
||||
var record = player.PeakRecord(peakId, hard: false);
|
||||
|
||||
var peak = new ChallengePeak
|
||||
{
|
||||
PeakId = peakId,
|
||||
HasPassed = record is not null,
|
||||
CyclesUsed = record?.Cycles ?? 0,
|
||||
};
|
||||
|
||||
var lineup = player.PeakLineups.GetValueOrDefault(peakId) ?? record?.Avatars ?? [];
|
||||
peak.PeakAvatarIdList.AddRange(lineup);
|
||||
peak.FinishedTargetList.AddRange(record?.FinishedTargets ?? []);
|
||||
|
||||
stars += record?.Stars ?? 0;
|
||||
proto.Peaks.Add(peak);
|
||||
}
|
||||
|
||||
proto.CountOfPeaks = (uint)proto.Peaks.Count;
|
||||
|
||||
if (group.BossLevelID > 0 && data.ChallengePeaks.ContainsKey(group.BossLevelID))
|
||||
{
|
||||
var easy = player.PeakRecord(group.BossLevelID, hard: false);
|
||||
var hard = player.PeakRecord(group.BossLevelID, hard: true);
|
||||
|
||||
var boss = new ChallengePeakBoss
|
||||
{
|
||||
HardModeHasPassed = hard is not null,
|
||||
EasyMode = Clearance(easy),
|
||||
HardMode = Clearance(hard),
|
||||
};
|
||||
|
||||
boss.FinishedTargetList.AddRange((easy?.FinishedTargets ?? []).Concat(hard?.FinishedTargets ?? []).Distinct());
|
||||
proto.PeakBoss = boss;
|
||||
|
||||
stars += easy?.Stars ?? 0;
|
||||
stars += hard?.Stars ?? 0;
|
||||
}
|
||||
|
||||
proto.ObtainedStars = stars;
|
||||
return proto;
|
||||
}
|
||||
|
||||
private static ChallengePeakBossClearance Clearance(PeakRecord? record)
|
||||
{
|
||||
var clearance = new ChallengePeakBossClearance
|
||||
{
|
||||
HasPassed = record is not null,
|
||||
BuffId = record?.BuffId ?? 0,
|
||||
BestCycleCount = record?.Cycles ?? 0,
|
||||
BestRecordBuffId = record?.BuffId ?? 0,
|
||||
};
|
||||
|
||||
clearance.PeakAvatarIdList.AddRange(record?.Avatars ?? []);
|
||||
return clearance;
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class ChallengeHandlers
|
||||
{
|
||||
// MOC floor N is locked until floor N-1 is cleared, and PF/AS want a score on the floor
|
||||
// before. reporting a full clear for anything unplayed keeps every floor reachable.
|
||||
private const uint AllStars = 7;
|
||||
|
||||
public static Task OnGetChallenge(PlayerSession session, GetChallengeCsReq request)
|
||||
{
|
||||
var data = session.World.Data;
|
||||
var records = session.Player.ChallengeRecords;
|
||||
var unlockAll = session.Config.GameServer.UnlockAllChallenges;
|
||||
var response = new GetChallengeScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var challenge in data.Challenges.Values.OrderBy(c => c.ID))
|
||||
{
|
||||
var record = records.GetValueOrDefault(challenge.ID);
|
||||
var clearScore = unlockAll ? ClearScore(data, challenge) : 0;
|
||||
|
||||
response.ChallengeList.Add(new Challenge
|
||||
{
|
||||
ChallengeId = challenge.ID,
|
||||
Star = record?.Stars ?? (unlockAll ? AllStars : 0),
|
||||
ScoreId = record?.Score ?? clearScore,
|
||||
ScoreTwo = record?.ScoreTwo ?? clearScore,
|
||||
TakenReward = 0,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var groupId in data.ChallengeGroups.Keys.Order())
|
||||
{
|
||||
response.ChallengeGroupList.Add(new ChallengeGroup { GroupId = groupId, TakenStarsCountReward = 0 });
|
||||
}
|
||||
|
||||
response.MaxLevelList.Add(new ChallengeHistoryMaxLevel { RewardDisplayType = 1, Level = 12 });
|
||||
response.MaxLevelList.Add(new ChallengeHistoryMaxLevel { RewardDisplayType = 2, Level = 4 });
|
||||
response.MaxLevelList.Add(new ChallengeHistoryMaxLevel { RewardDisplayType = 3, Level = 4 });
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// the score a PF or AS floor counts as fully cleared, straight out of its target rows
|
||||
private static uint ClearScore(GameData data, ChallengeMazeExcel challenge)
|
||||
{
|
||||
if (challenge.Kind == ChallengeKind.MemoryOfChaos)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return challenge.ChallengeTargetID
|
||||
.Select(id => data.ChallengeTargets.GetValueOrDefault(id))
|
||||
.Where(t => t?.ChallengeTargetType == "TOTAL_SCORE")
|
||||
.Select(t => t!.ChallengeTargetParam1)
|
||||
.DefaultIfEmpty(0u)
|
||||
.Max();
|
||||
}
|
||||
|
||||
public static Task OnGetCurChallenge(PlayerSession session, GetCurChallengeCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
var response = new GetCurChallengeScRsp { Retcode = 0 };
|
||||
|
||||
if (state.Active && !state.IsPeak)
|
||||
{
|
||||
response.CurChallenge = ChallengeFlow.BuildCurChallenge(session);
|
||||
response.LineupList.Add(session.BuildChallengeLineup(0));
|
||||
|
||||
if (state.NodeCount > 1)
|
||||
{
|
||||
response.LineupList.Add(session.BuildChallengeLineup(1));
|
||||
}
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetChallengeGroupStatistics(PlayerSession session, GetChallengeGroupStatisticsCsReq request) =>
|
||||
session.SendAsync(new GetChallengeGroupStatisticsScRsp { Retcode = 0, GroupId = request.GroupId });
|
||||
|
||||
public static Task OnTakeChallengeReward(PlayerSession session, TakeChallengeRewardCsReq request) =>
|
||||
session.SendAsync(new TakeChallengeRewardScRsp { Retcode = 0, GroupId = request.GroupId });
|
||||
|
||||
public static async Task OnStartChallenge(PlayerSession session, StartChallengeCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
var challenge = world.Data.GetChallenge(request.ChallengeId);
|
||||
|
||||
if (challenge is null)
|
||||
{
|
||||
session.Logger.LogWarning("unknown challenge {Id}", request.ChallengeId);
|
||||
await session.SendAsync(new StartChallengeScRsp { Retcode = (uint)Retcode.RetChallengeNotExist });
|
||||
return;
|
||||
}
|
||||
|
||||
var first = Lineup(request.AvatarLineupFirst, request.FirstLineup);
|
||||
var second = Lineup(request.AvatarLineupSecond, request.SecondLineup);
|
||||
|
||||
if (first.Count == 0)
|
||||
{
|
||||
first = second.Count > 0 ? second : session.DefaultLineup();
|
||||
}
|
||||
|
||||
// a two node floor wants a team per half; run the first one twice rather than refuse
|
||||
if (challenge.NodeCount > 1 && second.Count == 0)
|
||||
{
|
||||
second = first;
|
||||
}
|
||||
|
||||
var (buffOne, buffTwo) = challenge.Kind switch
|
||||
{
|
||||
ChallengeKind.PureFiction => (request.StageInfo?.StoryInfo?.BuffOne ?? 0u, request.StageInfo?.StoryInfo?.BuffTwo ?? 0u),
|
||||
ChallengeKind.ApocalypticShadow => (request.StageInfo?.BossInfo?.BuffOne ?? 0u, request.StageInfo?.BossInfo?.BuffTwo ?? 0u),
|
||||
_ => (0u, 0u),
|
||||
};
|
||||
|
||||
var state = session.Challenge;
|
||||
|
||||
if (!state.Start(world.Data, challenge, first, second, buffOne, buffTwo))
|
||||
{
|
||||
session.Logger.LogWarning("challenge {Id} has no usable scene", challenge.ID);
|
||||
await session.SendAsync(new StartChallengeScRsp { Retcode = (uint)Retcode.RetChallengeNotExist });
|
||||
return;
|
||||
}
|
||||
|
||||
session.Battles.Quit();
|
||||
|
||||
var book = session.Player.Lineups;
|
||||
book.ClearExtra();
|
||||
book.SetExtra(ExtraLineupType.LineupChallenge, state.Lineups[0], activate: false);
|
||||
|
||||
if (state.NodeCount > 1)
|
||||
{
|
||||
book.SetExtra(ExtraLineupType.LineupChallenge2, state.Lineups[1], activate: false);
|
||||
}
|
||||
|
||||
await ChallengeFlow.ActivateNodeAsync(session);
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"challenge {Id} ({Kind}) started: {Nodes} node(s), stage {Stage}, floor {Floor}, monster {Monster}, buffs [{Buffs}]",
|
||||
state.ChallengeId, state.Kind, state.NodeCount, state.StageId, state.FloorId, state.MonsterId,
|
||||
string.Join(", ", state.Blessings));
|
||||
|
||||
var scene = world.Scenes.BuildChallengeScene(state);
|
||||
session.Scene.Load(scene, isChallenge: true);
|
||||
|
||||
var response = new StartChallengeScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
CurChallenge = ChallengeFlow.BuildCurChallenge(session),
|
||||
Scene = scene,
|
||||
StageInfo = ChallengeFlow.BuildStageInfo(session),
|
||||
};
|
||||
|
||||
response.LineupList.Add(session.BuildChallengeLineup(0));
|
||||
|
||||
if (state.NodeCount > 1)
|
||||
{
|
||||
response.LineupList.Add(session.BuildChallengeLineup(1));
|
||||
}
|
||||
|
||||
await session.SendAsync(response);
|
||||
await session.NotifySpawnPositionAsync(scene);
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
public static async Task OnLeaveChallenge(PlayerSession session, LeaveChallengeCsReq request)
|
||||
{
|
||||
await session.SendAsync(new LeaveChallengeScRsp { Retcode = 0 });
|
||||
await LeaveAsync(session);
|
||||
}
|
||||
|
||||
// AS: the second boss half, requested once the phase settle screen is dismissed
|
||||
public static async Task OnEnterChallengeNextPhase(PlayerSession session, EnterChallengeNextPhaseCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (!state.Active || state.IsPeak || state.IsLastNode)
|
||||
{
|
||||
await session.SendAsync(new EnterChallengeNextPhaseScRsp { Retcode = (uint)Retcode.RetChallengeNotDoing });
|
||||
return;
|
||||
}
|
||||
|
||||
await ChallengeFlow.AdvanceNodeAsync(session);
|
||||
await session.SendAsync(new EnterChallengeNextPhaseScRsp { Retcode = 0, Scene = session.CurrentScene() });
|
||||
}
|
||||
|
||||
public static async Task OnRestartChallengePhase(PlayerSession session, RestartChallengePhaseCsReq request)
|
||||
{
|
||||
if (!session.Challenge.Active || session.Challenge.IsPeak)
|
||||
{
|
||||
await session.SendAsync(new RestartChallengePhaseScRsp { Retcode = (uint)Retcode.RetChallengeNotDoing });
|
||||
return;
|
||||
}
|
||||
|
||||
session.Battles.Quit();
|
||||
await ChallengeFlow.RestartNodeAsync(session);
|
||||
await session.SendAsync(new RestartChallengePhaseScRsp { Retcode = 0, Scene = session.CurrentScene() });
|
||||
}
|
||||
|
||||
// ---- Anomaly Arbitration (Challenge Peak) ----
|
||||
|
||||
public static Task OnGetChallengePeakData(PlayerSession session, GetChallengePeakDataCsReq request)
|
||||
{
|
||||
var data = session.World.Data;
|
||||
var response = new GetChallengePeakDataScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var group in data.ChallengePeakGroups.Values.OrderBy(g => g.ID))
|
||||
{
|
||||
response.ChallengePeakGroups.Add(ChallengeFlow.BuildPeakGroup(session, group));
|
||||
response.CurrentPeakGroupId = group.ID;
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetCurChallengePeak(PlayerSession session, GetCurChallengePeakCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
var response = new GetCurChallengePeakScRsp { Retcode = 0 };
|
||||
|
||||
if (state.Active && state.IsPeak)
|
||||
{
|
||||
response.PeakId = state.ChallengeId;
|
||||
response.BossBuffId = state.SelectedBuffs[0];
|
||||
response.HasPassed = state.Status == ChallengeStatus.ChallengeFinish;
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnSetChallengePeakBossHardMode(PlayerSession session, SetChallengePeakBossHardModeCsReq request)
|
||||
{
|
||||
session.Challenge.HardMode = request.IsHardMode;
|
||||
|
||||
return session.SendAsync(new SetChallengePeakBossHardModeScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
PeakGroupId = request.PeakGroupId,
|
||||
IsHardMode = request.IsHardMode,
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task OnSetChallengePeakMobLineupAvatar(PlayerSession session, SetChallengePeakMobLineupAvatarCsReq request)
|
||||
{
|
||||
foreach (var lineup in request.LineupList)
|
||||
{
|
||||
session.Player.PeakLineups[lineup.PeakId] = [.. lineup.PeakAvatarIdList.Where(session.OwnsBase)];
|
||||
}
|
||||
|
||||
session.Player.Touch();
|
||||
|
||||
if (session.World.Data.ChallengePeakGroups.TryGetValue(request.PeakGroupId, out var group))
|
||||
{
|
||||
await session.SendAsync(new ChallengePeakGroupDataUpdateScNotify
|
||||
{
|
||||
ChallengePeakGroup = ChallengeFlow.BuildPeakGroup(session, group),
|
||||
});
|
||||
}
|
||||
|
||||
await session.SendAsync(new SetChallengePeakMobLineupAvatarScRsp { Retcode = 0 });
|
||||
}
|
||||
|
||||
public static async Task OnStartChallengePeak(PlayerSession session, StartChallengePeakCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
|
||||
if (!world.Data.ChallengePeaks.TryGetValue(request.PeakId, out var peak))
|
||||
{
|
||||
session.Logger.LogWarning("unknown AA stage {Id}", request.PeakId);
|
||||
await session.SendAsync(new StartChallengePeakScRsp { Retcode = (uint)Retcode.RetChallengeNotExist });
|
||||
return;
|
||||
}
|
||||
|
||||
world.Data.ChallengePeakBosses.TryGetValue(peak.ID, out var boss);
|
||||
|
||||
var lineup = request.PeakAvatarIdList.Where(session.OwnsBase).ToList();
|
||||
|
||||
if (lineup.Count == 0)
|
||||
{
|
||||
lineup = session.Player.PeakLineups.GetValueOrDefault(peak.ID)?.Where(session.OwnsBase).ToList() ?? [];
|
||||
}
|
||||
|
||||
if (lineup.Count == 0)
|
||||
{
|
||||
lineup = session.DefaultLineup();
|
||||
}
|
||||
|
||||
if (!session.Challenge.StartPeak(world.Data, peak, boss, request.BossBuffId, lineup))
|
||||
{
|
||||
session.Logger.LogWarning("AA stage {Id} has no usable scene", peak.ID);
|
||||
await session.SendAsync(new StartChallengePeakScRsp { Retcode = (uint)Retcode.RetChallengeNotExist });
|
||||
return;
|
||||
}
|
||||
|
||||
session.Battles.Quit();
|
||||
|
||||
var state = session.Challenge;
|
||||
var book = session.Player.Lineups;
|
||||
book.ClearExtra();
|
||||
book.SetExtra(ExtraLineupType.LineupChallenge, state.Lineups[0], activate: false);
|
||||
await ChallengeFlow.ActivateNodeAsync(session);
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"AA stage {Id}{Hard}: stage {Stage}, floor {Floor}, monster {Monster}, tags [{Tags}]",
|
||||
state.ChallengeId, state.HardMode && boss is not null ? " (hard)" : string.Empty, state.StageId, state.FloorId,
|
||||
state.MonsterId, string.Join(", ", state.Blessings));
|
||||
|
||||
// AA delivers the scene out of band; the response itself is only a retcode
|
||||
await session.EnterChallengeSceneAsync();
|
||||
await session.SendAsync(new StartChallengePeakScRsp { Retcode = 0 });
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
public static async Task OnReStartChallengePeak(PlayerSession session, ReStartChallengePeakCsReq request)
|
||||
{
|
||||
var state = session.Challenge;
|
||||
|
||||
if (!state.Active || !state.IsPeak || state.Peak is null)
|
||||
{
|
||||
await session.SendAsync(new ReStartChallengePeakScRsp { Retcode = (uint)Retcode.RetChallengeNotDoing });
|
||||
return;
|
||||
}
|
||||
|
||||
session.Battles.Quit();
|
||||
state.StartPeak(session.World.Data, state.Peak, state.Boss, state.SelectedBuffs[0], state.Lineup.ToList());
|
||||
await ChallengeFlow.ActivateNodeAsync(session);
|
||||
await session.EnterChallengeSceneAsync();
|
||||
await session.SendAsync(new ReStartChallengePeakScRsp { Retcode = 0 });
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
public static async Task OnLeaveChallengePeak(PlayerSession session, LeaveChallengePeakCsReq request)
|
||||
{
|
||||
await session.SendAsync(new LeaveChallengePeakScRsp { Retcode = 0 });
|
||||
await LeaveAsync(session);
|
||||
}
|
||||
|
||||
public static Task OnConfirmChallengePeakSettle(PlayerSession session, ConfirmChallengePeakSettleCsReq request) =>
|
||||
session.SendAsync(new ConfirmChallengePeakSettleScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
PeakId = request.PeakId,
|
||||
IOBCIMIPEOM = request.IOBCIMIPEOM,
|
||||
});
|
||||
|
||||
public static Task OnTakeChallengePeakReward(PlayerSession session, TakeChallengePeakRewardCsReq request) =>
|
||||
session.SendAsync(new TakeChallengePeakRewardScRsp { Retcode = 0, PeakGroupId = request.PeakGroupId });
|
||||
|
||||
// back to the overworld the player left from
|
||||
private static Task LeaveAsync(PlayerSession session)
|
||||
{
|
||||
if (!session.Challenge.Active && !session.Scene.IsChallenge)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var player = session.Player;
|
||||
return session.EnterWorldSceneAsync(player.EntryId, player.TeleportId);
|
||||
}
|
||||
|
||||
private static List<uint> Lineup(IEnumerable<AvatarIdentifier> identifiers, IEnumerable<uint> plain)
|
||||
{
|
||||
var ids = identifiers.Select(a => a.Id).Where(id => id != 0).Distinct().ToList();
|
||||
return ids.Count > 0 ? ids : [.. plain.Where(id => id != 0).Distinct()];
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// slash commands typed at the CapySR friend in chat
|
||||
public static class ChatCommands
|
||||
{
|
||||
private delegate Task Command(PlayerSession session, string[] args);
|
||||
|
||||
private static readonly (string Name, string Usage, Command Run)[] Commands =
|
||||
[
|
||||
("help", "/help - this list", HelpAsync),
|
||||
("tp", "/tp <entryId> [teleportId] - travel to a map entrance", TeleportAsync),
|
||||
("unstuck", "/unstuck - respawn at the current map's anchor", UnstuckAsync),
|
||||
("heal", "/heal - full health and technique points", HealAsync),
|
||||
("mp", "/mp [n] - set technique points (default max)", MpAsync),
|
||||
("wl", "/wl <0-6> - set the equilibrium (world) level", WorldLevelAsync),
|
||||
("stage", "/stage <stageId> - the next fight you start plays this stage", StageAsync),
|
||||
("sync", "/sync - reload freesr-data.json from srtools now", SyncAsync),
|
||||
("lineup", "/lineup - show the walking team", LineupAsync),
|
||||
("pos", "/pos - show where you are", PositionAsync),
|
||||
("clear", "/clear - remove every monster on the current map", ClearAsync),
|
||||
];
|
||||
|
||||
public static async Task RunAsync(PlayerSession session, string line)
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
await HelpAsync(session, []);
|
||||
return;
|
||||
}
|
||||
|
||||
var name = parts[0].ToLowerInvariant();
|
||||
var command = Commands.FirstOrDefault(c => c.Name == name);
|
||||
|
||||
if (command.Run is null)
|
||||
{
|
||||
await session.SendBotMessageAsync($"Unknown command /{name}. Try /help.");
|
||||
return;
|
||||
}
|
||||
|
||||
session.Logger.LogInformation("command: /{Line}", line);
|
||||
|
||||
try
|
||||
{
|
||||
await command.Run(session, parts[1..]);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
session.Logger.LogError(e, "command /{Name} failed", name);
|
||||
await session.SendBotMessageAsync($"/{name} failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task HelpAsync(PlayerSession session, string[] args) =>
|
||||
session.SendBotMessageAsync(string.Join("\n", Commands.Select(c => c.Usage)));
|
||||
|
||||
private static async Task TeleportAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
if (args.Length == 0 || !uint.TryParse(args[0], out var entryId))
|
||||
{
|
||||
await session.SendBotMessageAsync("Usage: /tp <entryId> [teleportId]");
|
||||
return;
|
||||
}
|
||||
|
||||
var data = session.World.Data;
|
||||
|
||||
if (data.ResolveEntrance(entryId) is null && data.GetScene(entryId) is null)
|
||||
{
|
||||
await session.SendBotMessageAsync($"Entry {entryId} is not a known map entrance.");
|
||||
return;
|
||||
}
|
||||
|
||||
var teleportId = args.Length > 1 && uint.TryParse(args[1], out var t) ? t : 0u;
|
||||
|
||||
await session.SendBotMessageAsync($"Travelling to entry {entryId}.");
|
||||
await session.EnterWorldSceneAsync(entryId, teleportId);
|
||||
}
|
||||
|
||||
private static async Task UnstuckAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
await session.SendBotMessageAsync("Back to the anchor.");
|
||||
await session.EnterWorldSceneAsync(session.Player.EntryId, 0, EnterSceneReason.DimensionMerge);
|
||||
}
|
||||
|
||||
private static async Task HealAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
session.Player.HealAll(session.World.Player);
|
||||
session.Player.Lineups.RefillMp();
|
||||
session.Player.Touch();
|
||||
|
||||
await session.SyncLineupAsync(SyncLineupReason.SyncReasonHpAdd);
|
||||
await session.SendBotMessageAsync("Team healed, technique points refilled.");
|
||||
}
|
||||
|
||||
private static async Task MpAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
book.Mp = args.Length > 0 && uint.TryParse(args[0], out var n) ? n : book.MaxMp;
|
||||
session.Player.Touch();
|
||||
|
||||
await session.SyncLineupAsync(SyncLineupReason.SyncReasonMpAdd);
|
||||
await session.SendBotMessageAsync($"Technique points: {book.Mp}/{book.MaxMp}.");
|
||||
}
|
||||
|
||||
private static async Task WorldLevelAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
if (args.Length == 0 || !uint.TryParse(args[0], out var level) || level > 6)
|
||||
{
|
||||
await session.SendBotMessageAsync($"Usage: /wl <0-6> (now {session.Player.WorldLevel})");
|
||||
return;
|
||||
}
|
||||
|
||||
session.Player.WorldLevel = level;
|
||||
session.Player.Touch();
|
||||
|
||||
await session.SendAsync(new PlayerSyncScNotify { BasicInfo = LoginHandlers.BasicInfo(session) });
|
||||
await session.SendBotMessageAsync($"Equilibrium level {level}. Monsters will fight at that level from the next encounter.");
|
||||
}
|
||||
|
||||
private static async Task StageAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
if (args.Length == 0 || !uint.TryParse(args[0], out var stageId))
|
||||
{
|
||||
await session.SendBotMessageAsync("Usage: /stage <stageId>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.World.Data.GetStage(stageId) is not { } stage)
|
||||
{
|
||||
await session.SendBotMessageAsync($"Stage {stageId} does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
session.Player.NextStageOverride = stageId;
|
||||
|
||||
await session.SendBotMessageAsync(
|
||||
$"The next fight plays stage {stageId} ({stage.StageType}, level {stage.Level}, {stage.MonsterList.Count} wave(s)). Hit anything.");
|
||||
}
|
||||
|
||||
private static async Task SyncAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
session.World.Reload();
|
||||
await session.OnSrToolsReloadedAsync();
|
||||
await session.SendBotMessageAsync($"Reloaded srtools data: {session.World.OwnedAvatarIds.Count} avatars.");
|
||||
}
|
||||
|
||||
private static Task LineupAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
var lineup = book.Current;
|
||||
var members = string.Join(", ", lineup.Slots.Select(id => id == 0 ? "-" : id.ToString()));
|
||||
|
||||
return session.SendBotMessageAsync(
|
||||
$"{(book.InExtraLineup ? book.ExtraType.ToString() : lineup.Name)}: [{members}], leader {lineup.LeaderAvatarId}, " +
|
||||
$"technique points {book.Mp}/{book.MaxMp}.");
|
||||
}
|
||||
|
||||
private static Task PositionAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
var player = session.Player;
|
||||
var scene = session.Scene;
|
||||
var position = player.Position is { } p ? $"({p.X}, {p.Y}, {p.Z})" : "unknown";
|
||||
|
||||
return session.SendBotMessageAsync(
|
||||
$"Entry {scene.EntryId} (plane {scene.PlaneId}, floor {scene.FloorId}), teleport {player.TeleportId}, position {position}, " +
|
||||
$"{scene.Monsters.Count()} monsters around.");
|
||||
}
|
||||
|
||||
private static async Task ClearAsync(PlayerSession session, string[] args)
|
||||
{
|
||||
var monsters = session.Scene.Monsters.Select(m => m.EntityId).ToList();
|
||||
await session.RemoveEntitiesAsync(monsters);
|
||||
await session.SendBotMessageAsync($"Removed {monsters.Count} monster(s).");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// one friend on the list: the server itself. talking to it runs commands.
|
||||
[Handlers]
|
||||
public static class ChatHandlers
|
||||
{
|
||||
public const uint BotUid = 2000;
|
||||
public const string BotName = "CapySR";
|
||||
|
||||
public static Task OnGetFriendListInfo(PlayerSession session, GetFriendListInfoCsReq request)
|
||||
{
|
||||
var response = new GetFriendListInfoScRsp { Retcode = 0 };
|
||||
|
||||
response.FriendList.Add(new FriendSimpleInfo
|
||||
{
|
||||
CreateTime = 0,
|
||||
RemarkName = string.Empty,
|
||||
IsMarked = true,
|
||||
PlayerInfo = new PlayerSimpleInfo
|
||||
{
|
||||
Uid = BotUid,
|
||||
Nickname = BotName,
|
||||
Level = 70,
|
||||
HeadIcon = 201001,
|
||||
Signature = "Message me /help for commands",
|
||||
OnlineStatus = FriendOnlineStatus.Online,
|
||||
IsBanned = false,
|
||||
},
|
||||
});
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetChatFriendHistory(PlayerSession session, GetChatFriendHistoryCsReq request)
|
||||
{
|
||||
var response = new GetChatFriendHistoryScRsp { Retcode = 0 };
|
||||
|
||||
response.FriendHistoryInfo.Add(new FriendHistoryInfo
|
||||
{
|
||||
ContactSide = BotUid,
|
||||
LastSendTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
});
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetPrivateChatHistory(PlayerSession session, GetPrivateChatHistoryCsReq request)
|
||||
{
|
||||
var response = new GetPrivateChatHistoryScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
TargetSide = request.TargetSide,
|
||||
ContactSide = BotUid,
|
||||
};
|
||||
|
||||
if (request.ContactSide == BotUid || request.TargetSide == BotUid)
|
||||
{
|
||||
response.ChatMessageList.Add(BotMessage(session, "Welcome to CapySR. Type /help for the command list."));
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetChatEmojiList(PlayerSession session, GetChatEmojiListCsReq request) =>
|
||||
session.SendAsync(new GetChatEmojiListScRsp { Retcode = 0 });
|
||||
|
||||
public static async Task OnSendMsg(PlayerSession session, SendMsgCsReq request)
|
||||
{
|
||||
var chat = request.MessageDatas?.ChatData;
|
||||
var text = chat is { HasMessageText: true } ? chat.MessageText.Trim('\0').Trim() : string.Empty;
|
||||
|
||||
await session.SendAsync(new SendMsgScRsp { Retcode = 0 });
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.StartsWith('/'))
|
||||
{
|
||||
await ChatCommands.RunAsync(session, text[1..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
await session.SendBotMessageAsync("I only understand commands. Try /help.");
|
||||
}
|
||||
}
|
||||
|
||||
public static Task SendBotMessageAsync(this PlayerSession session, string text) =>
|
||||
session.SendAsync(new RevcMsgScNotify
|
||||
{
|
||||
ChatType = ChatType.Private,
|
||||
SourceUid = BotUid,
|
||||
RecvMessageData = BotMessage(session, text),
|
||||
});
|
||||
|
||||
private static ChatMessageData BotMessage(PlayerSession session, string text) => new()
|
||||
{
|
||||
CreateTime = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
CKHPFFENOBE = new EKNABKLPEEL { RoleId = BotUid, KPOBMNLKLOK = HCMEILLLKBD.Jdoaipkbipe },
|
||||
BKOALKHDLOB = new EKNABKLPEEL { RoleId = session.Player.Uid, KPOBMNLKLOK = HCMEILLLKBD.Jdoaipkbipe },
|
||||
MessageDatas =
|
||||
{
|
||||
new MessageChatData
|
||||
{
|
||||
MessageType = MsgType.CustomText,
|
||||
ChatData = new ChatData { MessageText = text },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// gear lives in the srtools file. edits made in-game are applied to the in-memory copy so the
|
||||
// character screen behaves, but they are not written back - srtools owns that file.
|
||||
[Handlers]
|
||||
public static class InventoryHandlers
|
||||
{
|
||||
public static Task OnGetBag(PlayerSession session, GetBagCsReq request)
|
||||
{
|
||||
var response = new GetBagScRsp { Retcode = 0 };
|
||||
|
||||
// the client needs at least some materials or its item map never initialises and
|
||||
// GetItemsInTypes throws every frame, freezing the character screen
|
||||
foreach (var (tid, count) in Materials)
|
||||
{
|
||||
response.MaterialList.Add(new Material { Tid = tid, Num = count });
|
||||
}
|
||||
|
||||
foreach (var relic in VisibleRelics(session))
|
||||
{
|
||||
response.RelicList.Add(ToProto(relic));
|
||||
}
|
||||
|
||||
foreach (var lightcone in VisibleLightcones(session))
|
||||
{
|
||||
response.EquipmentList.Add(ToProto(lightcone));
|
||||
}
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"bag: {Relics} relics, {Lightcones} lightcones, {Materials} materials (mode={Mode})",
|
||||
response.RelicList.Count, response.EquipmentList.Count, response.MaterialList.Count,
|
||||
session.Config.GameServer.InventoryMode);
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// all = everything, equipped = only what is worn, none = nothing
|
||||
public static IEnumerable<SrRelic> VisibleRelics(PlayerSession session)
|
||||
{
|
||||
var mode = session.Config.GameServer.InventoryMode.Trim().ToLowerInvariant();
|
||||
|
||||
if (mode == "none")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var equippedOnly = mode != "all";
|
||||
|
||||
return session.World.Player.Relics.Where(r =>
|
||||
r.RelicId != 0 && r.UniqueId != 0 && (!equippedOnly || r.EquipAvatar != 0));
|
||||
}
|
||||
|
||||
public static IEnumerable<SrLightcone> VisibleLightcones(PlayerSession session)
|
||||
{
|
||||
var mode = session.Config.GameServer.InventoryMode.Trim().ToLowerInvariant();
|
||||
|
||||
if (mode == "none")
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var equippedOnly = mode != "all";
|
||||
|
||||
return session.World.Player.Lightcones.Where(l =>
|
||||
l.ItemId != 0 && l.UniqueId != 0 && (!equippedOnly || l.EquipAvatar != 0));
|
||||
}
|
||||
|
||||
public static async Task OnDressAvatar(PlayerSession session, DressAvatarCsReq request)
|
||||
{
|
||||
var player = session.World.Player;
|
||||
var lightcone = player.Lightcones.FirstOrDefault(l => l.UniqueId == request.EquipmentUniqueId);
|
||||
|
||||
if (lightcone is not null)
|
||||
{
|
||||
// a lightcone can only be worn once
|
||||
foreach (var worn in player.Lightcones.Where(l => l.EquipAvatar == request.AvatarId))
|
||||
{
|
||||
worn.EquipAvatar = 0;
|
||||
}
|
||||
|
||||
lightcone.EquipAvatar = request.AvatarId;
|
||||
}
|
||||
|
||||
await session.SendAsync(new DressAvatarScRsp());
|
||||
await session.SyncGearAsync(request.AvatarId);
|
||||
}
|
||||
|
||||
public static async Task OnTakeOffEquipment(PlayerSession session, TakeOffEquipmentCsReq request)
|
||||
{
|
||||
foreach (var lightcone in session.World.Player.Lightcones.Where(l => l.EquipAvatar == request.AvatarId))
|
||||
{
|
||||
lightcone.EquipAvatar = 0;
|
||||
}
|
||||
|
||||
await session.SendAsync(new TakeOffEquipmentScRsp());
|
||||
await session.SyncGearAsync(request.AvatarId);
|
||||
}
|
||||
|
||||
public static async Task OnDressRelicAvatar(PlayerSession session, DressRelicAvatarCsReq request)
|
||||
{
|
||||
var player = session.World.Player;
|
||||
|
||||
foreach (var change in request.SwitchList)
|
||||
{
|
||||
var relic = player.Relics.FirstOrDefault(r => r.UniqueId == change.RelicUniqueId);
|
||||
if (relic is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// clear whatever was in that slot on this avatar
|
||||
foreach (var worn in player.Relics.Where(r => r.EquipAvatar == request.AvatarId && r.Slot == relic.Slot))
|
||||
{
|
||||
worn.EquipAvatar = 0;
|
||||
}
|
||||
|
||||
relic.EquipAvatar = request.AvatarId;
|
||||
}
|
||||
|
||||
await session.SendAsync(new DressRelicAvatarScRsp { Retcode = 0, AvatarId = request.AvatarId });
|
||||
await session.SyncGearAsync(request.AvatarId);
|
||||
}
|
||||
|
||||
public static async Task OnTakeOffRelic(PlayerSession session, TakeOffRelicCsReq request)
|
||||
{
|
||||
var player = session.World.Player;
|
||||
|
||||
foreach (var slot in request.RelicTypeList)
|
||||
{
|
||||
foreach (var relic in player.Relics.Where(r => r.EquipAvatar == request.AvatarId && r.Slot == slot))
|
||||
{
|
||||
relic.EquipAvatar = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await session.SendAsync(new TakeOffRelicScRsp());
|
||||
await session.SyncGearAsync(request.AvatarId);
|
||||
}
|
||||
|
||||
public static async Task OnRankUpAvatar(PlayerSession session, RankUpAvatarCsReq request)
|
||||
{
|
||||
if (session.World.Player.Avatars.TryGetValue(request.AvatarId, out var avatar))
|
||||
{
|
||||
avatar.Data.Rank = request.Rank;
|
||||
}
|
||||
|
||||
await session.SendAsync(new RankUpAvatarScRsp());
|
||||
await session.SyncGearAsync(request.AvatarId);
|
||||
}
|
||||
|
||||
// currencies, then the player outfits the wardrobe expects
|
||||
private static readonly (uint Tid, uint Count)[] Materials =
|
||||
[
|
||||
(101, 999_999), (238, 999_999), (239, 999_999),
|
||||
(251001, 100), (251002, 100), (251003, 100), (251004, 100),
|
||||
(227001, 1), (227002, 1), (227003, 1), (227004, 1), (227005, 1), (227006, 1),
|
||||
(227007, 1), (227008, 1), (227009, 1), (227010, 1), (227012, 1), (227013, 1),
|
||||
(227015, 1), (227016, 1), (229001, 1),
|
||||
];
|
||||
|
||||
public static Relic ToProto(SrRelic relic)
|
||||
{
|
||||
var proto = new Relic
|
||||
{
|
||||
UniqueId = relic.UniqueId,
|
||||
Tid = relic.RelicId,
|
||||
Level = relic.Level,
|
||||
MainAffixId = relic.MainAffixId,
|
||||
DressAvatarId = relic.EquipAvatar,
|
||||
Exp = 0,
|
||||
IsProtected = false,
|
||||
};
|
||||
|
||||
foreach (var affix in relic.SubAffixes)
|
||||
{
|
||||
proto.SubAffixList.Add(new RelicAffix
|
||||
{
|
||||
AffixId = affix.SubAffixId,
|
||||
Cnt = affix.Count,
|
||||
Step = affix.Step,
|
||||
});
|
||||
}
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static Equipment ToProto(SrLightcone lightcone) => new()
|
||||
{
|
||||
UniqueId = lightcone.UniqueId,
|
||||
Tid = lightcone.ItemId,
|
||||
Level = lightcone.Level,
|
||||
Promotion = lightcone.Promotion,
|
||||
Rank = lightcone.Rank,
|
||||
DressAvatarId = lightcone.EquipAvatar,
|
||||
Exp = 0,
|
||||
IsProtected = false,
|
||||
};
|
||||
|
||||
// push the avatar's new gear so the character screen updates without a relog
|
||||
public static Task SyncGearAsync(this PlayerSession session, uint avatarId)
|
||||
{
|
||||
var world = session.World;
|
||||
|
||||
var sync = new PlayerSyncScNotify
|
||||
{
|
||||
AvatarSync = new AvatarSync(),
|
||||
};
|
||||
|
||||
foreach (var relic in world.Player.Relics.Where(r => r.EquipAvatar == avatarId || r.EquipAvatar == 0))
|
||||
{
|
||||
sync.RelicList.Add(ToProto(relic));
|
||||
}
|
||||
|
||||
foreach (var lightcone in world.Player.Lightcones.Where(l => l.EquipAvatar == avatarId || l.EquipAvatar == 0))
|
||||
{
|
||||
sync.EquipmentList.Add(ToProto(lightcone));
|
||||
}
|
||||
|
||||
sync.AvatarSync.AvatarList.AddRange(Roster.BuildAvatars(world, session.Player));
|
||||
sync.AvatarSync.AvatarPathDataInfoList.AddRange(Roster.BuildPathData(world, session.Player));
|
||||
|
||||
return session.SendAsync(sync);
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.GameServer.Scene;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// The client redraws the party bar from SyncLineupNotify and the overworld models from the
|
||||
// scene, and it takes both as read only until the request it sent is answered. So every edit
|
||||
// pushes the scene and the lineup first and closes with the response, or the new team does
|
||||
// not show until something else nudges the UI.
|
||||
[Handlers]
|
||||
public static class LineupHandlers
|
||||
{
|
||||
public static Task OnGetAllLineupData(PlayerSession session, GetAllLineupDataCsReq request)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
var response = new GetAllLineupDataScRsp { Retcode = 0, CurIndex = book.CurrentIndex };
|
||||
|
||||
for (var i = 0; i < book.Squads.Count; i++)
|
||||
{
|
||||
response.LineupList.Add(session.BuildLineupInfo(book.Squads[i], ExtraLineupType.LineupNone, (uint)i));
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetCurLineupData(PlayerSession session, GetCurLineupDataCsReq request) =>
|
||||
session.SendAsync(new GetCurLineupDataScRsp { Retcode = 0, Lineup = session.BuildWorldLineup() });
|
||||
|
||||
public static async Task OnReplaceLineup(PlayerSession session, ReplaceLineupCsReq request)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
var entries = request.LineupSlotList
|
||||
.Where(s => session.OwnsBase(s.Id))
|
||||
.Select(s => (s.Slot, s.Id))
|
||||
.ToList();
|
||||
|
||||
Lineup target;
|
||||
bool isCurrent;
|
||||
|
||||
if (request.ExtraLineupType != ExtraLineupType.LineupNone)
|
||||
{
|
||||
target = book.SetExtra(request.ExtraLineupType, entries.Select(e => e.Id));
|
||||
isCurrent = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
target = book.Squad(request.Index);
|
||||
target.Replace(entries, request.LeaderSlot);
|
||||
isCurrent = !book.InExtraLineup && request.Index == book.CurrentIndex;
|
||||
}
|
||||
|
||||
// never leave the walking team empty; put the roster's first back
|
||||
if (target.IsEmpty && ReferenceEquals(target, book.Current))
|
||||
{
|
||||
target.Fill(session.DefaultLineup());
|
||||
}
|
||||
|
||||
session.Player.Touch();
|
||||
session.LogTeam($"replace #{request.Index}", target);
|
||||
|
||||
await session.PushTeamAsync(isCurrent);
|
||||
await session.SendAsync(new ReplaceLineupScRsp { Retcode = 0 });
|
||||
}
|
||||
|
||||
public static async Task OnJoinLineup(PlayerSession session, JoinLineupCsReq request)
|
||||
{
|
||||
var target = session.Target(request.ExtraLineupType, request.Index);
|
||||
var avatarId = request.OPCJCDKMJEI;
|
||||
|
||||
var retcode = !session.OwnsBase(avatarId)
|
||||
? Retcode.RetLineupAvatarNotExist
|
||||
: target.Join(avatarId, request.Slot);
|
||||
|
||||
session.LogTeam($"join {avatarId} -> slot {request.Slot} ({retcode})", target);
|
||||
|
||||
if (retcode == Retcode.RetSucc)
|
||||
{
|
||||
session.Player.Touch();
|
||||
await session.PushTeamAsync(session.IsCurrent(request.ExtraLineupType, request.Index));
|
||||
}
|
||||
|
||||
await session.SendAsync(new JoinLineupScRsp { Retcode = (uint)retcode });
|
||||
}
|
||||
|
||||
public static async Task OnQuitLineup(PlayerSession session, QuitLineupCsReq request)
|
||||
{
|
||||
var target = session.Target(request.ExtraLineupType, request.Index);
|
||||
var retcode = target.Quit(request.BaseAvatarId);
|
||||
|
||||
session.LogTeam($"quit {request.BaseAvatarId} ({retcode})", target);
|
||||
|
||||
if (retcode == Retcode.RetSucc)
|
||||
{
|
||||
session.Player.Touch();
|
||||
await session.PushTeamAsync(session.IsCurrent(request.ExtraLineupType, request.Index));
|
||||
}
|
||||
|
||||
await session.SendAsync(new QuitLineupScRsp { Retcode = (uint)retcode });
|
||||
}
|
||||
|
||||
public static async Task OnSwapLineup(PlayerSession session, SwapLineupCsReq request)
|
||||
{
|
||||
var target = session.Target(request.ExtraLineupType, request.Index);
|
||||
var retcode = target.Swap(request.MPIEGFCNBEN, request.ENBIBCPPLDC);
|
||||
|
||||
session.LogTeam($"swap {request.MPIEGFCNBEN} <-> {request.ENBIBCPPLDC} ({retcode})", target);
|
||||
|
||||
if (retcode == Retcode.RetSucc)
|
||||
{
|
||||
session.Player.Touch();
|
||||
await session.PushTeamAsync(session.IsCurrent(request.ExtraLineupType, request.Index));
|
||||
}
|
||||
|
||||
await session.SendAsync(new SwapLineupScRsp { Retcode = (uint)retcode });
|
||||
}
|
||||
|
||||
public static async Task OnChangeLineupLeader(PlayerSession session, ChangeLineupLeaderCsReq request)
|
||||
{
|
||||
var lineup = session.Player.Lineups.Current;
|
||||
var retcode = lineup.SetLeader(request.Slot);
|
||||
|
||||
session.LogTeam($"leader -> slot {request.Slot} ({retcode})", lineup);
|
||||
|
||||
if (retcode == Retcode.RetSucc)
|
||||
{
|
||||
session.Player.Touch();
|
||||
|
||||
if (session.Scene.Info is { } scene)
|
||||
{
|
||||
scene.LeaderEntityId = SceneBuilder.ActorEntityId(session.World.Data.BaseAvatarId(lineup.LeaderAvatarId));
|
||||
}
|
||||
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
await session.SendAsync(new ChangeLineupLeaderScRsp
|
||||
{
|
||||
Retcode = (uint)retcode,
|
||||
Slot = request.Slot,
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task OnSwitchLineupIndex(PlayerSession session, SwitchLineupIndexCsReq request)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
var retcode = book.InExtraLineup ? Retcode.RetLineupInvalidIndex : book.SetCurrent(request.Index);
|
||||
|
||||
session.LogTeam($"switch -> #{request.Index} ({retcode})", book.Current);
|
||||
|
||||
if (retcode == Retcode.RetSucc)
|
||||
{
|
||||
session.Player.Touch();
|
||||
await session.PushTeamAsync(isCurrent: true);
|
||||
}
|
||||
|
||||
await session.SendAsync(new SwitchLineupIndexScRsp
|
||||
{
|
||||
Retcode = (uint)retcode,
|
||||
Index = request.Index,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnSetLineupName(PlayerSession session, SetLineupNameCsReq request)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
|
||||
if (request.Index >= LineupBook.SquadCount)
|
||||
{
|
||||
return session.SendAsync(new SetLineupNameScRsp { Retcode = (uint)Retcode.RetLineupInvalidIndex });
|
||||
}
|
||||
|
||||
book.Squad(request.Index).Name = request.Name;
|
||||
session.Player.Touch();
|
||||
|
||||
return session.SendAsync(new SetLineupNameScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Index = request.Index,
|
||||
Name = request.Name,
|
||||
});
|
||||
}
|
||||
|
||||
// every owned character with its current health, for the team picker
|
||||
public static Task OnGetLineupAvatarData(PlayerSession session, GetLineupAvatarDataCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
var response = new GetLineupAvatarDataScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var baseId in world.OwnedAvatarIds.Select(world.Data.BaseAvatarId).Distinct())
|
||||
{
|
||||
var path = Roster.ResolvePath(world, session.Player, baseId);
|
||||
|
||||
response.AvatarDataList.Add(new LineupAvatarData
|
||||
{
|
||||
Id = baseId,
|
||||
AvatarType = AvatarType.AvatarFormalType,
|
||||
Hp = session.Player.StateOf(path, world.Player).Hp,
|
||||
});
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
private static async Task PushTeamAsync(this PlayerSession session, bool isCurrent)
|
||||
{
|
||||
if (isCurrent)
|
||||
{
|
||||
await session.RefreshSceneActorsAsync();
|
||||
}
|
||||
|
||||
await session.SyncLineupAsync();
|
||||
}
|
||||
|
||||
private static Lineup Target(this PlayerSession session, ExtraLineupType type, uint index)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
|
||||
return type != ExtraLineupType.LineupNone
|
||||
? book.Extra(type) ?? book.SetExtra(type, [], activate: false)
|
||||
: book.Squad(index);
|
||||
}
|
||||
|
||||
private static bool IsCurrent(this PlayerSession session, ExtraLineupType type, uint index)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
|
||||
return type != ExtraLineupType.LineupNone
|
||||
? book.ExtraType == type
|
||||
: !book.InExtraLineup && index == book.CurrentIndex;
|
||||
}
|
||||
|
||||
// a multipath character is addressed by its base id; owning any path counts
|
||||
public static bool OwnsBase(this PlayerSession session, uint avatarId)
|
||||
{
|
||||
if (avatarId == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var world = session.World;
|
||||
return world.OwnedAvatarIds.Any(id => id == avatarId || world.Data.BaseAvatarId(id) == avatarId);
|
||||
}
|
||||
|
||||
private static void LogTeam(this PlayerSession session, string what, Lineup lineup)
|
||||
{
|
||||
session.Logger.LogInformation("lineup {What}: [{Team}] leader {Leader}",
|
||||
what, string.Join(", ", lineup.Slots.Select(id => id == 0 ? "-" : id.ToString())), lineup.LeaderAvatarId);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class LoginHandlers
|
||||
{
|
||||
public static Task OnGetToken(PlayerSession session, PlayerGetTokenCsReq request) =>
|
||||
session.SendAsync(new PlayerGetTokenScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Uid = session.Player.Uid,
|
||||
SecretKeySeed = 0,
|
||||
});
|
||||
|
||||
public static Task OnLogin(PlayerSession session, PlayerLoginCsReq request)
|
||||
{
|
||||
var player = session.Player;
|
||||
player.LoggedIn = true;
|
||||
player.Language = request.HOFEMNAIIHB;
|
||||
|
||||
session.Logger.LogInformation("login: client language {Language}, res version {Version}",
|
||||
request.HOFEMNAIIHB, request.ClientResVersion);
|
||||
|
||||
return session.SendAsync(new PlayerLoginScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
LoginRandom = request.LoginRandom,
|
||||
ServerTimestampMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
Stamina = player.Stamina,
|
||||
BasicInfo = BasicInfo(session),
|
||||
});
|
||||
}
|
||||
|
||||
public static PlayerBasicInfo BasicInfo(PlayerSession session)
|
||||
{
|
||||
var player = session.Player;
|
||||
|
||||
return new PlayerBasicInfo
|
||||
{
|
||||
Nickname = player.Nickname,
|
||||
Level = player.Level,
|
||||
WorldLevel = player.WorldLevel,
|
||||
Stamina = player.Stamina,
|
||||
Mcoin = 999_999,
|
||||
Hcoin = 999_999,
|
||||
Scoin = 999_999,
|
||||
};
|
||||
}
|
||||
|
||||
public static Task OnLoginFinish(PlayerSession session, PlayerLoginFinishCsReq request) =>
|
||||
session.SendAsync(new PlayerLoginFinishScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetBasicInfo(PlayerSession session, GetBasicInfoCsReq request) =>
|
||||
session.SendAsync(new GetBasicInfoScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
IsGenderSet = true,
|
||||
Gender = (uint)session.Player.Gender,
|
||||
CurDay = 1,
|
||||
// PlayerModule dereferences this one without checking
|
||||
PlayerSettingInfo = new PlayerSettingInfo(),
|
||||
});
|
||||
|
||||
public static Task OnHeartBeat(PlayerSession session, PlayerHeartBeatCsReq request) =>
|
||||
session.SendAsync(new PlayerHeartBeatScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
ClientTimeMs = request.ClientTimeMs,
|
||||
ServerTimeMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// small answers the client polls for on login. most are "nothing here" said properly.
|
||||
[Handlers]
|
||||
public static class MiscHandlers
|
||||
{
|
||||
private const uint StandardBannerId = 1001;
|
||||
|
||||
// client settings the game stores server side (auto battle, speed, ...)
|
||||
public static Task OnGetAllServerPrefsData(PlayerSession session, GetAllServerPrefsDataCsReq request)
|
||||
{
|
||||
var response = new GetAllServerPrefsDataScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var (id, data) in session.Player.ServerPrefs.OrderBy(kv => kv.Key))
|
||||
{
|
||||
response.ServerPrefsList.Add(new ServerPrefs { ServerPrefsId = id, Data = ByteString.CopyFrom(data) });
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnUpdateServerPrefs(PlayerSession session, UpdateServerPrefsCsReq request)
|
||||
{
|
||||
var prefs = request.ServerPrefs;
|
||||
|
||||
if (prefs is not null)
|
||||
{
|
||||
session.Player.ServerPrefs[prefs.ServerPrefsId] = prefs.Data.ToByteArray();
|
||||
session.Player.Touch();
|
||||
}
|
||||
|
||||
return session.SendAsync(new UpdateServerPrefsDataScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
ServerPrefsId = prefs?.ServerPrefsId ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnGetMarkItemList(PlayerSession session, GetMarkItemListCsReq request) =>
|
||||
session.SendAsync(new GetMarkItemListScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetCurAssist(PlayerSession session, GetCurAssistCsReq request) =>
|
||||
session.SendAsync(new GetCurAssistScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetMissionData(PlayerSession session, GetMissionDataCsReq request)
|
||||
{
|
||||
var response = new GetMissionDataScRsp { Retcode = 0 };
|
||||
response.FinishedMainMissionIdList.AddRange(session.World.Data.MainMissions.Keys);
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetQuestData(PlayerSession session, GetQuestDataCsReq request) =>
|
||||
session.SendAsync(new GetQuestDataScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetActivityScheduleConfig(PlayerSession session, GetActivityScheduleConfigCsReq request) =>
|
||||
session.SendAsync(new GetActivityScheduleConfigScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetSecretKeyInfo(PlayerSession session, GetSecretKeyInfoCsReq request) =>
|
||||
session.SendAsync(new GetSecretKeyInfoScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetVideoVersionKey(PlayerSession session, GetVideoVersionKeyCsReq request) =>
|
||||
session.SendAsync(new GetVideoVersionKeyScRsp { Retcode = 0 });
|
||||
|
||||
// an empty list leaves the gacha red-dot filter indexing nil every UI tick, which throws
|
||||
// out of UIManager.Tick every frame. hand it the standard banner and nothing else.
|
||||
public static Task OnGetGachaInfo(PlayerSession session, GetGachaInfoCsReq request)
|
||||
{
|
||||
var ceiling = new GachaCeiling { CeilingNum = 300, IsClaimed = false };
|
||||
|
||||
foreach (var avatarId in session.World.Data.GachaCeilingAvatars)
|
||||
{
|
||||
ceiling.AvatarList.Add(new GachaCeilingAvatar { AvatarId = avatarId, RepeatedCnt = 300 });
|
||||
}
|
||||
|
||||
var response = new GetGachaInfoScRsp { Retcode = 0, GachaRandom = 0 };
|
||||
|
||||
response.GachaInfoList.Add(new GachaInfo
|
||||
{
|
||||
GachaId = StandardBannerId,
|
||||
GachaCeiling = ceiling,
|
||||
BeginTime = 0,
|
||||
EndTime = 2524608000,
|
||||
});
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetDailyActiveInfo(PlayerSession session, GetDailyActiveInfoCsReq request) =>
|
||||
session.SendAsync(new GetDailyActiveInfoScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetUnreleasedBlockInfo(PlayerSession session, GetUnreleasedBlockInfoCsReq request) =>
|
||||
session.SendAsync(new GetUnreleasedBlockInfoScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetLevelRewardTakenList(PlayerSession session, GetLevelRewardTakenListCsReq request) =>
|
||||
session.SendAsync(new GetLevelRewardTakenListScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetPreAvatarGrowthInfo(PlayerSession session, GetPreAvatarGrowthInfoCsReq request) =>
|
||||
session.SendAsync(new GetPreAvatarGrowthInfoScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnGetBigDataAllRecommend(PlayerSession session, GetBigDataAllRecommendCsReq request) =>
|
||||
session.SendAsync(new GetBigDataAllRecommendScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnTextJoinQuery(PlayerSession session, TextJoinQueryCsReq request) =>
|
||||
session.SendAsync(new TextJoinQueryScRsp { Retcode = 0 });
|
||||
|
||||
public static Task OnUpdateTrackMainMission(PlayerSession session, UpdateTrackMainMissionCsReq request) =>
|
||||
session.SendAsync(new UpdateTrackMainMissionScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
TrackMissionId = request.TrackMissionId,
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// everything is finished. the client blocks on this during load: without a reply it
|
||||
// never decides which scene it is supposed to be in and sits on a black screen.
|
||||
[Handlers]
|
||||
public static class MissionHandlers
|
||||
{
|
||||
public static Task OnGetMissionStatus(PlayerSession session, GetMissionStatusCsReq request)
|
||||
{
|
||||
var response = new GetMissionStatusScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var id in request.SubMissionIdList)
|
||||
{
|
||||
response.SubMissionStatusList.Add(new Mission
|
||||
{
|
||||
Id = id,
|
||||
Status = MissionStatus.MissionFinish,
|
||||
Progress = 1,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var id in session.World.Data.MainMissions.Keys)
|
||||
{
|
||||
response.FinishedMainMissionIdList.Add(id);
|
||||
response.CurversionFinishedMainMissionIdList.Add(id);
|
||||
}
|
||||
|
||||
// same story as GetMainMissionCustomValue: the ids asked about need a row each
|
||||
foreach (var id in request.MainMissionIdList)
|
||||
{
|
||||
response.MainMissionMcvList.Add(new MainMissionCustomValue
|
||||
{
|
||||
MainMissionId = id,
|
||||
CustomValueList = new ONHKODAFEMH(),
|
||||
});
|
||||
}
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"missions: {Sub} sub finished, {Main} main finished",
|
||||
response.SubMissionStatusList.Count, response.FinishedMainMissionIdList.Count);
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// an entry has to come back for every id asked about. the client indexes the result by
|
||||
// mission id while it activates the map's hoyo groups, and a miss throws inside
|
||||
// AdventureModule.EnterMap - the map never finishes entering and the load screen stays up.
|
||||
public static Task OnGetMainMissionCustomValue(PlayerSession session, GetMainMissionCustomValueCsReq request)
|
||||
{
|
||||
var response = new GetMainMissionCustomValueScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var id in request.MainMissionIdList)
|
||||
{
|
||||
response.MainMissionList.Add(new MainMission
|
||||
{
|
||||
Id = id,
|
||||
Status = MissionStatus.MissionFinish,
|
||||
});
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// report every tutorial already finished. an empty list makes the client try to unlock
|
||||
// them one at a time, forever, and it never leaves the loading screen.
|
||||
public static Task OnGetTutorial(PlayerSession session, GetTutorialCsReq request)
|
||||
{
|
||||
var response = new GetTutorialScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var id in session.World.Data.TutorialIds)
|
||||
{
|
||||
response.TutorialList.Add(new Tutorial { Id = id, Status = TutorialStatus.TutorialFinish });
|
||||
}
|
||||
|
||||
session.Logger.LogInformation("tutorials: {Count} finished", response.TutorialList.Count);
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetTutorialGuide(PlayerSession session, GetTutorialGuideCsReq request)
|
||||
{
|
||||
var response = new GetTutorialGuideScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var id in session.World.Data.TutorialGuideIds)
|
||||
{
|
||||
response.TutorialGuideList.Add(new TutorialGuide { Id = id, Status = TutorialStatus.TutorialFinish });
|
||||
}
|
||||
|
||||
session.Logger.LogInformation("tutorial guides: {Count} finished", response.TutorialGuideList.Count);
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class ProfileHandlers
|
||||
{
|
||||
private const uint HeadIconBase = 200_000;
|
||||
|
||||
public static Task OnGetPlayerBoardData(PlayerSession session, GetPlayerBoardDataCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
var player = session.Player;
|
||||
|
||||
var response = new GetPlayerBoardDataScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Signature = player.Signature,
|
||||
CurrentHeadIconId = player.HeadIcon,
|
||||
DisplayAvatarVec = new DisplayAvatarVec { IsDisplay = true },
|
||||
};
|
||||
|
||||
// one portrait per owned character; the icon table is the authority when present
|
||||
var owned = Roster.OwnedIds(world).ToHashSet();
|
||||
|
||||
var icons = world.Data.HeadIcons.Count > 0
|
||||
? world.Data.HeadIcons.Values.Where(i => owned.Contains(i.AvatarID)).Select(i => i.ID)
|
||||
: owned.Select(id => HeadIconBase + id);
|
||||
|
||||
foreach (var iconId in icons.Order())
|
||||
{
|
||||
response.UnlockedHeadIconList.Add(new HeadIconData { Id = iconId });
|
||||
}
|
||||
|
||||
var shown = player.DisplayAvatars.Count > 0 ? player.DisplayAvatars : player.Lineups.Current.AvatarIds;
|
||||
|
||||
for (var i = 0; i < shown.Count; i++)
|
||||
{
|
||||
response.DisplayAvatarVec.DisplayAvatarList.Add(new DisplayAvatarData { Pos = (uint)i, AvatarId = shown[i] });
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnSetDisplayAvatar(PlayerSession session, SetDisplayAvatarCsReq request)
|
||||
{
|
||||
var player = session.Player;
|
||||
player.DisplayAvatars.Clear();
|
||||
|
||||
foreach (var entry in request.DisplayAvatarList.OrderBy(d => d.Pos))
|
||||
{
|
||||
if (session.OwnsBase(entry.AvatarId) && !player.DisplayAvatars.Contains(entry.AvatarId))
|
||||
{
|
||||
player.DisplayAvatars.Add(entry.AvatarId);
|
||||
}
|
||||
}
|
||||
|
||||
player.Touch();
|
||||
|
||||
var response = new SetDisplayAvatarScRsp { Retcode = 0 };
|
||||
|
||||
for (var i = 0; i < player.DisplayAvatars.Count; i++)
|
||||
{
|
||||
response.DisplayAvatarList.Add(new DisplayAvatarData { Pos = (uint)i, AvatarId = player.DisplayAvatars[i] });
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnSetHeadIcon(PlayerSession session, SetHeadIconCsReq request)
|
||||
{
|
||||
session.Player.HeadIcon = request.Id;
|
||||
session.Player.Touch();
|
||||
|
||||
return session.SendAsync(new SetHeadIconScRsp { Retcode = 0, CurrentHeadIconId = request.Id });
|
||||
}
|
||||
|
||||
public static Task OnSetSignature(PlayerSession session, SetSignatureCsReq request)
|
||||
{
|
||||
session.Player.Signature = request.Signature;
|
||||
session.Player.Touch();
|
||||
return session.SendAsync(new SetSignatureScRsp { Retcode = 0, Signature = request.Signature });
|
||||
}
|
||||
|
||||
public static Task OnSetNickname(PlayerSession session, SetNicknameCsReq request)
|
||||
{
|
||||
session.Player.Nickname = request.Nickname;
|
||||
session.Player.Touch();
|
||||
|
||||
return session.SendAsync(new SetNicknameScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
IsModify = request.IsModify,
|
||||
SetTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
});
|
||||
}
|
||||
|
||||
// the client picks its own language; echo the choice back or the selector appears to do nothing
|
||||
public static Task OnSetLanguage(PlayerSession session, SetLanguageCsReq request)
|
||||
{
|
||||
session.Player.Language = request.HOFEMNAIIHB;
|
||||
session.Logger.LogInformation("language set to {Language}", request.HOFEMNAIIHB);
|
||||
|
||||
return session.SendAsync(new SetLanguageScRsp { Retcode = 0, HOFEMNAIIHB = request.HOFEMNAIIHB });
|
||||
}
|
||||
|
||||
public static Task OnGetPlayerDetailInfo(PlayerSession session, GetPlayerDetailInfoCsReq request) =>
|
||||
session.SendAsync(new GetPlayerDetailInfoScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
DetailInfo = new PlayerDetailInfo
|
||||
{
|
||||
Uid = session.Player.Uid,
|
||||
Nickname = session.Player.Nickname,
|
||||
Signature = session.Player.Signature,
|
||||
Level = session.Player.Level,
|
||||
WorldLevel = session.Player.WorldLevel,
|
||||
HeadIcon = session.Player.HeadIcon,
|
||||
IsBanned = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.GameServer.Scene;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
[Handlers]
|
||||
public static class SceneHandlers
|
||||
{
|
||||
public static Task OnGetCurSceneInfo(PlayerSession session, GetCurSceneInfoCsReq request)
|
||||
{
|
||||
var scene = session.CurrentScene();
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"scene {Entry}: plane {Plane}, floor {Floor}, {Groups} groups, {Entities} entities",
|
||||
scene.EntryId, scene.PlaneId, scene.FloorId, scene.EntityGroupList.Count,
|
||||
scene.EntityGroupList.Sum(g => g.EntityList.Count));
|
||||
|
||||
return session.SendAsync(new GetCurSceneInfoScRsp { Retcode = 0, Scene = scene });
|
||||
}
|
||||
|
||||
public static async Task OnEnterScene(PlayerSession session, EnterSceneCsReq request)
|
||||
{
|
||||
var world = session.World;
|
||||
var entryId = request.EntryId != 0 ? request.EntryId : session.Player.EntryId;
|
||||
var resolved = world.Data.ResolveEntrance(entryId);
|
||||
var floorId = resolved?.Entrance.FloorID ?? request.SceneIdentifier?.FloorId ?? 0;
|
||||
|
||||
// echo whatever the client sent back at it; dropping content or storyline ids here
|
||||
// leaves it waiting on a scene it cannot match to the request
|
||||
var identifier = request.SceneIdentifier?.Clone() ?? new SceneIdentifier();
|
||||
|
||||
if (floorId != 0)
|
||||
{
|
||||
identifier.FloorId = floorId;
|
||||
}
|
||||
|
||||
session.Logger.LogInformation(
|
||||
"enter scene: entry {Entry} (from {Previous}), teleport {Teleport}, plane {Plane}, floor {Floor}",
|
||||
entryId, session.Player.EntryId, request.TeleportId, resolved?.Entrance.PlaneID ?? 0, floorId);
|
||||
|
||||
await session.SendAsync(new EnterSceneScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
SceneIdentifier = identifier,
|
||||
IsCloseMap = request.IsCloseMap,
|
||||
IsOverMap = true,
|
||||
});
|
||||
|
||||
await session.EnterWorldSceneAsync(entryId, request.TeleportId);
|
||||
}
|
||||
|
||||
public static Task OnGetSceneMapInfo(PlayerSession session, GetSceneMapInfoCsReq request)
|
||||
{
|
||||
var response = new GetSceneMapInfoScRsp { Retcode = 0 };
|
||||
var data = session.World.Data;
|
||||
|
||||
foreach (var identifier in request.SceneIdentifiers)
|
||||
{
|
||||
var info = new SceneMapInfo
|
||||
{
|
||||
Retcode = 0,
|
||||
FloorId = identifier.FloorId,
|
||||
EntryId = session.Player.EntryId,
|
||||
CurMapEntryId = session.Player.EntryId,
|
||||
SceneIdentifier = identifier,
|
||||
};
|
||||
|
||||
// everything lit and every teleport open
|
||||
if (data.FloorSavedValues.TryGetValue(identifier.FloorId, out var saved))
|
||||
{
|
||||
foreach (var value in saved.SavedValues.Where(v => v.Name.Length > 0))
|
||||
{
|
||||
info.FloorSavedValueMap[value.Name] = value.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
info.UnlockTeleportList.AddRange(data.TeleportIdsForFloor(identifier.FloorId));
|
||||
info.ClientGroupMissionInfo = new MissionStatusBySceneInfo();
|
||||
info.LightenSectionList.AddRange(LightenSections());
|
||||
|
||||
foreach (var scene in data.Scenes.Values.Where(s => s.PlaneID == identifier.FloorId / 1000))
|
||||
{
|
||||
foreach (var prop in scene.Props)
|
||||
{
|
||||
info.MazePropList.Add(new MazePropState
|
||||
{
|
||||
GroupId = prop.GroupId,
|
||||
ConfigId = prop.InstId,
|
||||
State = prop.PropState,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var groupId in scene.Props.Select(p => p.GroupId).Distinct())
|
||||
{
|
||||
info.MazeGroupList.Add(new MazeGroup { GroupId = groupId });
|
||||
}
|
||||
}
|
||||
|
||||
response.SceneMapInfo.Add(info);
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// section ids are small per floor; lighting more than exists is harmless
|
||||
private static IEnumerable<uint> LightenSections()
|
||||
{
|
||||
for (var i = 0u; i <= 100; i++)
|
||||
{
|
||||
yield return i;
|
||||
}
|
||||
|
||||
for (var i = 10000u; i <= 10050; i++)
|
||||
{
|
||||
yield return i;
|
||||
}
|
||||
|
||||
yield return 20000;
|
||||
|
||||
for (var i = 30000u; i <= 30019; i++)
|
||||
{
|
||||
yield return i;
|
||||
}
|
||||
}
|
||||
|
||||
public static Task OnSceneEntityMove(PlayerSession session, SceneEntityMoveCsReq request)
|
||||
{
|
||||
// challenge maps are not where the player lives; only overworld steps are remembered
|
||||
if (!session.Scene.IsChallenge)
|
||||
{
|
||||
foreach (var motion in request.EntityMotionList)
|
||||
{
|
||||
if (motion.Motion is null || !IsParty(session, motion.EntityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
session.Player.Position = PlayerPosition.From(motion.Motion);
|
||||
session.Player.Touch();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var response = new SceneEntityMoveScRsp { Retcode = 0 };
|
||||
response.EntityMotionList.AddRange(request.EntityMotionList);
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// the client reports the party as entity 0 in some builds and as the leader in others
|
||||
private static bool IsParty(PlayerSession session, uint entityId) =>
|
||||
entityId == 0 || session.Scene.IsActor(entityId);
|
||||
|
||||
public static async Task OnSceneEntityTeleport(PlayerSession session, SceneEntityTeleportCsReq request)
|
||||
{
|
||||
if (request.EntityMotion?.Motion is { } motion && !session.Scene.IsChallenge)
|
||||
{
|
||||
session.Player.Position = PlayerPosition.From(motion);
|
||||
session.Player.Touch();
|
||||
}
|
||||
|
||||
await session.SendAsync(new SceneEntityTeleportScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
EntityMotion = request.EntityMotion,
|
||||
ClientPosVersion = 1,
|
||||
});
|
||||
}
|
||||
|
||||
public static Task OnGetEnteredScene(PlayerSession session, GetEnteredSceneCsReq request)
|
||||
{
|
||||
var response = new GetEnteredSceneScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var entrance in session.World.Data.MapEntrances.Values)
|
||||
{
|
||||
response.EnteredSceneInfoList.Add(new EnteredSceneInfo
|
||||
{
|
||||
PlaneId = entrance.PlaneID,
|
||||
FloorId = entrance.FloorID,
|
||||
});
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
// the client asks per entry; answering with every teleport in the game makes it filter
|
||||
// points that belong to maps it is not looking at
|
||||
public static Task OnGetUnlockTeleport(PlayerSession session, GetUnlockTeleportCsReq request)
|
||||
{
|
||||
var data = session.World.Data;
|
||||
var response = new GetUnlockTeleportScRsp { Retcode = 0 };
|
||||
|
||||
var teleports = request.EntryIdList.Count > 0
|
||||
? request.EntryIdList.SelectMany(data.TeleportIdsForEntry)
|
||||
: data.Scenes.Values.SelectMany(s => s.Teleports).Select(t => t.TeleportId);
|
||||
|
||||
response.UnlockedTeleportList.AddRange(teleports.Distinct());
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnEnterSection(PlayerSession session, EnterSectionCsReq request) =>
|
||||
session.SendAsync(new EnterSectionScRsp { Retcode = 0 });
|
||||
|
||||
// space anchors bring the team back to full and refill technique points
|
||||
public static async Task OnInteractProp(PlayerSession session, InteractPropCsReq request)
|
||||
{
|
||||
var prop = session.Scene.Get(request.PropEntityId);
|
||||
var excel = prop is null ? null : session.World.Data.Props.GetValueOrDefault(prop.PropId);
|
||||
|
||||
await session.SendAsync(new InteractPropScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
PropEntityId = request.PropEntityId,
|
||||
PropState = prop?.PropState ?? 0,
|
||||
});
|
||||
|
||||
if (excel is { IsSpring: true })
|
||||
{
|
||||
session.Player.HealAll(session.World.Player);
|
||||
session.Player.Lineups.RefillMp();
|
||||
session.Player.Touch();
|
||||
await session.SyncLineupAsync(SyncLineupReason.SyncReasonHpAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.GameServer.Scene;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
public static class SessionExtensions
|
||||
{
|
||||
// base ids of the team walking around, or the first four the account owns
|
||||
public static List<uint> DefaultLineup(this PlayerSession session)
|
||||
{
|
||||
var current = session.Player.Lineups.Current.AvatarIds;
|
||||
|
||||
if (current.Count > 0)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
return [.. session.World.OwnedAvatarIds.Select(session.World.Data.BaseAvatarId).Distinct().Take(Lineup.Size)];
|
||||
}
|
||||
|
||||
public static SceneInfo CurrentScene(this PlayerSession session)
|
||||
{
|
||||
if (session.Scene.Info is { } info)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
var scene = session.Challenge.Active
|
||||
? session.World.Scenes.BuildChallengeScene(session.Challenge)
|
||||
: session.World.Scenes.BuildWorldScene(
|
||||
session.Player.EntryId, session.DefaultLineup(), session.Player.TeleportId,
|
||||
session.Config.GameServer.WorldId, session.Player.Position);
|
||||
|
||||
session.Scene.Load(scene, session.Challenge.Active);
|
||||
return scene;
|
||||
}
|
||||
|
||||
public static LineupInfo BuildLineupInfo(this PlayerSession session, Lineup lineup, ExtraLineupType type, uint index)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
var isCurrent = ReferenceEquals(lineup, book.Current);
|
||||
var maxMp = isCurrent ? book.MaxMp : MaxMpFor(lineup);
|
||||
|
||||
var planeId = session.Scene.PlaneId != 0
|
||||
? session.Scene.PlaneId
|
||||
: session.World.Data.ResolveEntrance(session.Player.EntryId)?.Entrance.PlaneID ?? 0;
|
||||
|
||||
var info = new LineupInfo
|
||||
{
|
||||
Name = lineup.Name,
|
||||
ExtraLineupType = type,
|
||||
LeaderSlot = lineup.LeaderSlot,
|
||||
Mp = Math.Min(book.Mp, maxMp),
|
||||
MaxMp = maxMp,
|
||||
PlaneId = planeId,
|
||||
Index = type == ExtraLineupType.LineupNone ? index : 0,
|
||||
IsVirtual = type != ExtraLineupType.LineupNone,
|
||||
};
|
||||
|
||||
foreach (var (slot, avatarId) in lineup.Occupied)
|
||||
{
|
||||
var path = Roster.ResolvePath(session.World, session.Player, avatarId);
|
||||
var state = session.Player.StateOf(path, session.World.Player);
|
||||
|
||||
info.AvatarList.Add(new LineupAvatar
|
||||
{
|
||||
Id = avatarId,
|
||||
Slot = slot,
|
||||
Hp = state.Hp,
|
||||
Satiety = 100,
|
||||
AvatarType = AvatarType.AvatarFormalType,
|
||||
SpBar = state.SpBar,
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static uint MaxMpFor(Lineup lineup) =>
|
||||
lineup.AvatarIds.Any(id => id is 1408 or 1510) ? LineupBook.BaseMaxMp + 3 : LineupBook.BaseMaxMp;
|
||||
|
||||
public static LineupInfo BuildWorldLineup(this PlayerSession session)
|
||||
{
|
||||
var book = session.Player.Lineups;
|
||||
return session.BuildLineupInfo(book.Current, book.ExtraType, book.CurrentIndex);
|
||||
}
|
||||
|
||||
public static LineupInfo BuildChallengeLineup(this PlayerSession session, int node)
|
||||
{
|
||||
var type = node == 0 ? ExtraLineupType.LineupChallenge : ExtraLineupType.LineupChallenge2;
|
||||
var lineup = session.Player.Lineups.Extra(type)
|
||||
?? session.Player.Lineups.SetExtra(type, session.Challenge.Lineups[node], activate: false);
|
||||
|
||||
return session.BuildLineupInfo(lineup, type, 0);
|
||||
}
|
||||
|
||||
// the client only redraws the team once the server confirms it
|
||||
public static Task SyncLineupAsync(this PlayerSession session, SyncLineupReason reason = SyncLineupReason.SyncReasonNone)
|
||||
{
|
||||
var notify = new SyncLineupNotify { Lineup = session.BuildWorldLineup() };
|
||||
|
||||
if (reason != SyncLineupReason.SyncReasonNone)
|
||||
{
|
||||
notify.ReasonList.Add(reason);
|
||||
}
|
||||
|
||||
return session.SendAsync(notify);
|
||||
}
|
||||
|
||||
// the overworld models are the scene's actor entities; swap them in place instead of
|
||||
// reloading the whole scene when the team changes
|
||||
public static async Task RefreshSceneActorsAsync(this PlayerSession session)
|
||||
{
|
||||
var scene = session.Scene;
|
||||
|
||||
if (scene.Info is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = session.World.Data;
|
||||
var lineup = session.Player.Lineups.Current;
|
||||
var desired = lineup.AvatarIds.Select(data.BaseAvatarId).Distinct().ToList();
|
||||
var existing = scene.Actors.ToList();
|
||||
|
||||
var removed = existing.Where(a => !desired.Contains(a.AvatarId)).ToList();
|
||||
var added = desired.Where(id => existing.All(a => a.AvatarId != id)).ToList();
|
||||
|
||||
var leaderEntityId = SceneBuilder.ActorEntityId(data.BaseAvatarId(lineup.LeaderAvatarId));
|
||||
|
||||
if (removed.Count == 0 && added.Count == 0)
|
||||
{
|
||||
scene.Info.LeaderEntityId = leaderEntityId;
|
||||
return;
|
||||
}
|
||||
|
||||
var motion = session.Player.Position?.ToMotion()
|
||||
?? existing.FirstOrDefault()?.Motion
|
||||
?? session.World.Scenes.SpawnMotion(scene.EntryId, session.Player.TeleportId, null);
|
||||
|
||||
var actors = desired.Select(id => session.World.Scenes.BuildActor(id, motion)).ToList();
|
||||
scene.ReplaceActors(actors, leaderEntityId);
|
||||
|
||||
var group = new GroupRefreshInfo
|
||||
{
|
||||
GroupId = 0,
|
||||
RefreshType = removed.Count > 0 ? SceneGroupRefreshType.Afibfmafncc : SceneGroupRefreshType.Loaded,
|
||||
};
|
||||
|
||||
foreach (var actor in removed)
|
||||
{
|
||||
group.RefreshEntity.Add(new SceneEntityRefreshInfo { DeleteEntity = actor.EntityId });
|
||||
}
|
||||
|
||||
foreach (var actor in actors.Where(a => added.Contains(a.Actor.BaseAvatarId)))
|
||||
{
|
||||
group.RefreshEntity.Add(new SceneEntityRefreshInfo { AddEntity = actor });
|
||||
}
|
||||
|
||||
await session.SendAsync(new SceneGroupRefreshScNotify
|
||||
{
|
||||
FloorId = scene.FloorId,
|
||||
GroupRefreshList = { group },
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task RemoveEntitiesAsync(this PlayerSession session, IEnumerable<uint> entityIds)
|
||||
{
|
||||
var scene = session.Scene;
|
||||
var groups = new Dictionary<uint, GroupRefreshInfo>();
|
||||
|
||||
foreach (var entityId in entityIds.Distinct())
|
||||
{
|
||||
var entity = scene.Get(entityId);
|
||||
|
||||
if (entity is null || !scene.Remove(entityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.TryGetValue(entity.GroupId, out var group))
|
||||
{
|
||||
groups[entity.GroupId] = group = new GroupRefreshInfo
|
||||
{
|
||||
GroupId = entity.GroupId,
|
||||
RefreshType = SceneGroupRefreshType.Afibfmafncc,
|
||||
};
|
||||
}
|
||||
|
||||
group.RefreshEntity.Add(new SceneEntityRefreshInfo { DeleteEntity = entityId });
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var notify = new SceneGroupRefreshScNotify { FloorId = scene.FloorId };
|
||||
notify.GroupRefreshList.AddRange(groups.Values);
|
||||
await session.SendAsync(notify);
|
||||
}
|
||||
|
||||
// a real scene change: teleport, map travel, leaving a challenge
|
||||
public static async Task EnterWorldSceneAsync(
|
||||
this PlayerSession session, uint entryId, uint teleportId, EnterSceneReason reason = EnterSceneReason.None,
|
||||
bool keepPosition = false)
|
||||
{
|
||||
var player = session.Player;
|
||||
var world = session.World;
|
||||
|
||||
session.Challenge.Clear();
|
||||
session.Battles.Quit();
|
||||
player.Lineups.ClearExtra();
|
||||
|
||||
if (world.Data.ResolveEntrance(entryId) is null && world.Data.GetScene(entryId) is null)
|
||||
{
|
||||
session.Logger.LogWarning("entry {Entry} is unknown; staying at {Current}", entryId, player.EntryId);
|
||||
entryId = player.EntryId;
|
||||
}
|
||||
|
||||
player.EntryId = entryId;
|
||||
player.TeleportId = teleportId;
|
||||
|
||||
if (!keepPosition)
|
||||
{
|
||||
player.Position = null;
|
||||
}
|
||||
|
||||
player.Lineups.RefillMp();
|
||||
player.HealAll(world.Player);
|
||||
player.Touch();
|
||||
|
||||
var scene = world.Scenes.BuildWorldScene(
|
||||
entryId, session.DefaultLineup(), teleportId, session.Config.GameServer.WorldId,
|
||||
keepPosition ? player.Position : null);
|
||||
|
||||
session.Scene.Load(scene, isChallenge: false);
|
||||
|
||||
await session.SendAsync(new EnterSceneByServerScNotify
|
||||
{
|
||||
Reason = reason,
|
||||
Lineup = session.BuildWorldLineup(),
|
||||
Scene = scene,
|
||||
});
|
||||
|
||||
await session.NotifySpawnPositionAsync(scene);
|
||||
}
|
||||
|
||||
public static async Task EnterChallengeSceneAsync(this PlayerSession session)
|
||||
{
|
||||
var scene = session.World.Scenes.BuildChallengeScene(session.Challenge);
|
||||
session.Scene.Load(scene, isChallenge: true);
|
||||
|
||||
await session.SendAsync(new EnterSceneByServerScNotify
|
||||
{
|
||||
Reason = EnterSceneReason.None,
|
||||
Lineup = session.BuildWorldLineup(),
|
||||
Scene = scene,
|
||||
});
|
||||
|
||||
await session.NotifySpawnPositionAsync(scene);
|
||||
}
|
||||
|
||||
// the client places actors where the move notify says, so repeat the spawn for each
|
||||
public static async Task NotifySpawnPositionAsync(this PlayerSession session, SceneInfo scene)
|
||||
{
|
||||
foreach (var group in scene.EntityGroupList)
|
||||
{
|
||||
foreach (var entity in group.EntityList.Where(e => e.Actor is not null && e.Motion is not null))
|
||||
{
|
||||
await session.SendAsync(new SceneEntityMoveScNotify
|
||||
{
|
||||
EntityId = entity.EntityId,
|
||||
EntryId = scene.EntryId,
|
||||
Motion = entity.Motion,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task MoveActorsAsync(this PlayerSession session, MotionInfo motion)
|
||||
{
|
||||
var scene = session.Scene;
|
||||
|
||||
foreach (var actor in scene.Actors.ToList())
|
||||
{
|
||||
await session.SendAsync(new SceneEntityMoveScNotify
|
||||
{
|
||||
EntityId = actor.EntityId,
|
||||
EntryId = scene.EntryId,
|
||||
Motion = motion,
|
||||
});
|
||||
}
|
||||
|
||||
session.Player.Position = PlayerPosition.From(motion);
|
||||
}
|
||||
|
||||
// heal the walking team; the fallen stay down, as in the real thing
|
||||
public static void HealParty(this PlayerSession session, uint amount)
|
||||
{
|
||||
foreach (var avatarId in session.Player.Lineups.Current.AvatarIds)
|
||||
{
|
||||
var path = Roster.ResolvePath(session.World, session.Player, avatarId);
|
||||
var state = session.Player.StateOf(path, session.World.Player);
|
||||
|
||||
if (state.Hp > 0)
|
||||
{
|
||||
state.Hp = Math.Min(Player.FullHp, state.Hp + amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Task SyncAvatarsAsync(this PlayerSession session)
|
||||
{
|
||||
var sync = new PlayerSyncScNotify { AvatarSync = new AvatarSync() };
|
||||
sync.AvatarSync.AvatarList.AddRange(Roster.BuildAvatars(session.World, session.Player));
|
||||
sync.AvatarSync.AvatarPathDataInfoList.AddRange(Roster.BuildPathData(session.World, session.Player));
|
||||
return session.SendAsync(sync);
|
||||
}
|
||||
|
||||
// a fresh srtools upload: new roster, new gear, teams pruned to what still exists
|
||||
public static async Task OnSrToolsReloadedAsync(this PlayerSession session)
|
||||
{
|
||||
var world = session.World;
|
||||
var player = session.Player;
|
||||
|
||||
session.Logger.LogInformation("srtools data changed; pushing {Avatars} avatars to the client", world.OwnedAvatarIds.Count);
|
||||
|
||||
var owned = world.OwnedAvatarIds.Select(world.Data.BaseAvatarId).Distinct().ToList();
|
||||
player.Lineups.Prune(id => owned.Contains(id));
|
||||
player.Lineups.Seed(owned.Take(Lineup.Size));
|
||||
|
||||
if (player.Lineups.Current.IsEmpty)
|
||||
{
|
||||
player.Lineups.Squad(player.Lineups.CurrentIndex).Fill(owned.Take(Lineup.Size));
|
||||
}
|
||||
|
||||
player.AvatarStates.Clear();
|
||||
player.Touch();
|
||||
|
||||
var sync = new PlayerSyncScNotify { AvatarSync = new AvatarSync() };
|
||||
sync.AvatarSync.AvatarList.AddRange(Roster.BuildAvatars(world, player));
|
||||
sync.AvatarSync.AvatarPathDataInfoList.AddRange(Roster.BuildPathData(world, player));
|
||||
|
||||
foreach (var relic in InventoryHandlers.VisibleRelics(session))
|
||||
{
|
||||
sync.RelicList.Add(InventoryHandlers.ToProto(relic));
|
||||
}
|
||||
|
||||
foreach (var lightcone in InventoryHandlers.VisibleLightcones(session))
|
||||
{
|
||||
sync.EquipmentList.Add(InventoryHandlers.ToProto(lightcone));
|
||||
}
|
||||
|
||||
await session.SendAsync(sync);
|
||||
await session.SyncLineupAsync();
|
||||
|
||||
if (!session.Challenge.Active)
|
||||
{
|
||||
await session.RefreshSceneActorsAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Handlers;
|
||||
|
||||
// the client retries these until answered, so an unhandled one stalls the load forever
|
||||
[Handlers]
|
||||
public static class TutorialHandlers
|
||||
{
|
||||
// the client reads rsp.Tutorial / rsp.TutorialGuide without a nil check, so leaving
|
||||
// them unset throws in TutorialModule.lua and the load never gets past the loading
|
||||
// screen. echo the id back and call it finished, like the rest of the tutorial state.
|
||||
public static Task OnUnlockTutorial(PlayerSession session, UnlockTutorialCsReq request) =>
|
||||
session.SendAsync(new UnlockTutorialScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Tutorial = Tutorial(request.HPIMDINCCNN),
|
||||
});
|
||||
|
||||
public static Task OnFinishTutorial(PlayerSession session, FinishTutorialCsReq request) =>
|
||||
session.SendAsync(new FinishTutorialScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Tutorial = Tutorial(request.TutorialId),
|
||||
});
|
||||
|
||||
public static Task OnUnlockTutorialGuide(PlayerSession session, UnlockTutorialGuideCsReq request) =>
|
||||
session.SendAsync(new UnlockTutorialGuideScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
TutorialGuide = Guide(request.GroupId, request.Type),
|
||||
});
|
||||
|
||||
public static Task OnFinishTutorialGuide(PlayerSession session, FinishTutorialGuideCsReq request) =>
|
||||
session.SendAsync(new FinishTutorialGuideScRsp
|
||||
{
|
||||
Retcode = 0,
|
||||
Reward = new ItemList(),
|
||||
TutorialGuide = Guide(request.GroupId, request.Type),
|
||||
});
|
||||
|
||||
private static Tutorial Tutorial(uint id) =>
|
||||
new() { Id = id, Status = TutorialStatus.TutorialFinish };
|
||||
|
||||
private static TutorialGuide Guide(uint groupId, TutorialGuideType type) =>
|
||||
new() { Id = groupId, Type = type, Status = TutorialStatus.TutorialFinish };
|
||||
|
||||
public static Task OnSetClientPaused(PlayerSession session, SetClientPausedCsReq request) =>
|
||||
session.SendAsync(new SetClientPausedScRsp { Retcode = 0, Paused = request.Paused });
|
||||
|
||||
public static Task OnGetArchiveData(PlayerSession session, GetArchiveDataCsReq request) =>
|
||||
session.SendAsync(new GetArchiveDataScRsp { Retcode = 0, ArchiveData = new ArchiveData() });
|
||||
|
||||
public static Task OnGetFirstTalkNpc(PlayerSession session, GetFirstTalkNpcCsReq request)
|
||||
{
|
||||
var response = new GetFirstTalkNpcScRsp { Retcode = 0 };
|
||||
|
||||
foreach (var npcId in request.NpcIdList)
|
||||
{
|
||||
response.NpcMeetStatusList.Add(new FirstNpcTalkInfo { NpcId = npcId, IsMeet = true });
|
||||
}
|
||||
|
||||
return session.SendAsync(response);
|
||||
}
|
||||
|
||||
public static Task OnGetMail(PlayerSession session, GetMailCsReq request) =>
|
||||
session.SendAsync(new GetMailScRsp { Retcode = 0, IsEnd = true, TotalNum = 0, Start = 0 });
|
||||
|
||||
public static Task OnGetShopList(PlayerSession session, GetShopListCsReq request) =>
|
||||
session.SendAsync(new GetShopListScRsp { Retcode = 0, ShopType = request.ShopType });
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Reflection;
|
||||
using CapySR.Protocol;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Net;
|
||||
|
||||
// marks a class whose static methods are packet handlers
|
||||
[AttributeUsage(AttributeTargets.Class)]
|
||||
public sealed class HandlersAttribute : Attribute;
|
||||
|
||||
public delegate Task PacketHandler(PlayerSession session, IMessage request);
|
||||
|
||||
// handlers are static methods taking (PlayerSession, TRequest); the cmdid comes from TRequest
|
||||
public sealed class HandlerRegistry
|
||||
{
|
||||
private readonly Dictionary<ushort, PacketHandler> _handlers = [];
|
||||
|
||||
public int HandlerCount => _handlers.Count;
|
||||
|
||||
public static HandlerRegistry Build(ILogger logger)
|
||||
{
|
||||
var registry = new HandlerRegistry();
|
||||
|
||||
foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
|
||||
{
|
||||
if (type.GetCustomAttribute<HandlersAttribute>() is not null)
|
||||
{
|
||||
registry.RegisterHandlers(type, logger);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private void RegisterHandlers(Type type, ILogger logger)
|
||||
{
|
||||
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
|
||||
if (parameters.Length != 2 || parameters[0].ParameterType != typeof(PlayerSession))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var requestType = parameters[1].ParameterType;
|
||||
if (!typeof(IMessage).IsAssignableFrom(requestType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cmdId = ProtocolRegistry.GetCmdId((IMessage)Activator.CreateInstance(requestType)!);
|
||||
if (cmdId is null)
|
||||
{
|
||||
logger.LogWarning("{Type}.{Method} takes {Request}, which has no cmdid",
|
||||
type.Name, method.Name, requestType.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = method;
|
||||
|
||||
// two handlers for one request would silently shadow each other
|
||||
if (_handlers.ContainsKey(cmdId.Value))
|
||||
{
|
||||
logger.LogWarning("{Type}.{Method} re-registers {Request}; the earlier handler is replaced",
|
||||
type.Name, method.Name, requestType.Name);
|
||||
}
|
||||
|
||||
_handlers[cmdId.Value] = (session, request) =>
|
||||
{
|
||||
var result = target.Invoke(null, [session, request]);
|
||||
return result as Task ?? Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetHandler(ushort cmdId, out PacketHandler handler) =>
|
||||
_handlers.TryGetValue(cmdId, out handler!);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Net;
|
||||
|
||||
public sealed class KcpGateway(ServerConfig config, HandlerRegistry handlers, GameWorld world, ILogger<KcpGateway> logger)
|
||||
{
|
||||
private const int MaxDatagram = 1500;
|
||||
|
||||
private readonly ConcurrentDictionary<uint, PlayerSession> _sessions = [];
|
||||
private Socket? _socket;
|
||||
private uint _nextConversation;
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = new IPEndPoint(IPAddress.Parse(config.GameServer.Host), config.GameServer.Port);
|
||||
|
||||
_socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
_socket.Bind(endpoint);
|
||||
|
||||
// windows udp resets the socket when an ICMP unreachable comes back; ignore it
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_socket.IOControl(unchecked((int)0x9800000C), [0, 0, 0, 0], null);
|
||||
}
|
||||
|
||||
logger.LogInformation("kcp gateway listening on {Endpoint}", endpoint);
|
||||
|
||||
_ = Task.Run(() => TickLoopAsync(cancellationToken), cancellationToken);
|
||||
|
||||
var buffer = new byte[MaxDatagram];
|
||||
var from = new IPEndPoint(IPAddress.Any, 0);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
SocketReceiveFromResult result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await _socket.ReceiveFromAsync(buffer, SocketFlags.None, from, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
logger.LogDebug("recv failed ({Error})", e.SocketErrorCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
var datagram = buffer.AsSpan(0, result.ReceivedBytes).ToArray();
|
||||
|
||||
switch (datagram.Length)
|
||||
{
|
||||
case NetOperation.Size:
|
||||
await HandleOperationAsync(datagram, result.RemoteEndPoint);
|
||||
break;
|
||||
|
||||
case >= Kcp.Kcp.Overhead:
|
||||
await HandlePayloadAsync(datagram);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogDebug("ignoring {Length}-byte datagram from {From}", datagram.Length, result.RemoteEndPoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// shutting down: let every session write out what it remembers
|
||||
foreach (var (key, session) in _sessions)
|
||||
{
|
||||
session.Close();
|
||||
_sessions.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleOperationAsync(byte[] datagram, EndPoint remote)
|
||||
{
|
||||
if (!NetOperation.TryRead(datagram, out var operation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.IsConnect)
|
||||
{
|
||||
var conversation = Interlocked.Increment(ref _nextConversation);
|
||||
var token = (uint)Random.Shared.Next(1, int.MaxValue);
|
||||
|
||||
// one player at a time for now; a reconnect replaces the old session
|
||||
foreach (var (key, existing) in _sessions)
|
||||
{
|
||||
existing.Close();
|
||||
_sessions.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
var session = new PlayerSession(
|
||||
conversation, token, _socket!, remote, handlers, config, world, logger);
|
||||
|
||||
_sessions[conversation] = session;
|
||||
|
||||
logger.LogInformation("conv {Conv}: connected from {Remote}", conversation, remote);
|
||||
|
||||
var accept = NetOperation.Accept(conversation, token, operation.Data);
|
||||
await _socket!.SendToAsync(accept.ToArray(), SocketFlags.None, remote);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.IsDisconnect)
|
||||
{
|
||||
if (_sessions.TryGetValue(operation.Param1, out var session) && session.Token == operation.Param2)
|
||||
{
|
||||
session.Close();
|
||||
_sessions.TryRemove(operation.Param1, out _);
|
||||
logger.LogInformation("conv {Conv}: disconnected", operation.Param1);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogWarning("unknown handshake magic {Head:X}-{Tail:X}", operation.Head, operation.Tail);
|
||||
}
|
||||
|
||||
private async Task HandlePayloadAsync(byte[] datagram)
|
||||
{
|
||||
var conversation = Kcp.Kcp.GetConversation(datagram);
|
||||
|
||||
if (!_sessions.TryGetValue(conversation, out var session))
|
||||
{
|
||||
logger.LogDebug("no session for conv {Conv}", conversation);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.ReceiveAsync(datagram);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "conv {Conv}: session error", conversation);
|
||||
}
|
||||
|
||||
if (session.IsClosed)
|
||||
{
|
||||
_sessions.TryRemove(conversation, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// kcp needs a heartbeat even when the client is quiet, for retransmits
|
||||
private async Task TickLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(20));
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
foreach (var (conversation, session) in _sessions)
|
||||
{
|
||||
await session.TickAsync();
|
||||
|
||||
if (session.IsClosed)
|
||||
{
|
||||
_sessions.TryRemove(conversation, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Battle;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Handlers;
|
||||
using CapySR.GameServer.Scene;
|
||||
using CapySR.Protocol;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CapySR.GameServer.Net;
|
||||
|
||||
public sealed class PlayerSession
|
||||
{
|
||||
private const ushort HeartBeatCmdId = 44;
|
||||
private const ushort HeartBeatRspCmdId = 62;
|
||||
private const ushort SceneEntityMoveCmdId = 1434;
|
||||
private const ushort SceneEntityMoveRspCmdId = 1425;
|
||||
private const ushort LogoutCmdId = 5;
|
||||
|
||||
private static readonly TimeSpan SaveInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Kcp.Kcp _kcp;
|
||||
private readonly Socket _socket;
|
||||
private readonly EndPoint _remote;
|
||||
private readonly HandlerRegistry _handlers;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Stopwatch _clock = Stopwatch.StartNew();
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly HashSet<ushort> _warnedUnimplemented = [];
|
||||
|
||||
private volatile bool _reloadPending;
|
||||
private DateTime _lastSave = DateTime.UtcNow;
|
||||
|
||||
public PlayerSession(
|
||||
uint conversation, uint token, Socket socket, EndPoint remote,
|
||||
HandlerRegistry handlers, ServerConfig config, GameWorld world, ILogger logger)
|
||||
{
|
||||
Conversation = conversation;
|
||||
Token = token;
|
||||
Config = config;
|
||||
World = world;
|
||||
Player = new Player(config.GameServer);
|
||||
_socket = socket;
|
||||
_remote = remote;
|
||||
_handlers = handlers;
|
||||
_logger = logger;
|
||||
|
||||
Scene = new SceneState();
|
||||
Challenge = new ChallengeState();
|
||||
Battles = new BattleController(this);
|
||||
|
||||
if (config.GameServer.Persist && PlayerStore.TryLoad(world.PlayerStorePath, Player))
|
||||
{
|
||||
_logger.LogInformation("conv {Conv}: restored player state from {Path}", conversation, world.PlayerStorePath);
|
||||
}
|
||||
|
||||
SeedLineups();
|
||||
world.PlayerReloaded += OnPlayerReloaded;
|
||||
|
||||
_kcp = new Kcp.Kcp(conversation, token, SendDatagram);
|
||||
_kcp.SetNoDelay(true, 10, 2, true);
|
||||
_kcp.SetWindowSize(256, 256);
|
||||
}
|
||||
|
||||
public uint Conversation { get; }
|
||||
|
||||
public uint Token { get; }
|
||||
|
||||
public ServerConfig Config { get; }
|
||||
|
||||
public GameWorld World { get; }
|
||||
|
||||
public ILogger Logger => _logger;
|
||||
|
||||
public Player Player { get; }
|
||||
|
||||
public SceneState Scene { get; }
|
||||
|
||||
public ChallengeState Challenge { get; }
|
||||
|
||||
public BattleController Battles { get; }
|
||||
|
||||
public bool IsClosed { get; private set; }
|
||||
|
||||
private uint Now => (uint)_clock.ElapsedMilliseconds;
|
||||
|
||||
// squads store base ids; the roster lists paths
|
||||
private void SeedLineups()
|
||||
{
|
||||
var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList();
|
||||
|
||||
Player.Lineups.Prune(id => owned.Contains(id));
|
||||
Player.Lineups.Seed(owned.Take(Lineup.Size));
|
||||
|
||||
// the walking team must never be empty, whichever squad is selected
|
||||
if (Player.Lineups.Current.IsEmpty)
|
||||
{
|
||||
Player.Lineups.Squad(Player.Lineups.CurrentIndex).Fill(owned.Take(Lineup.Size));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlayerReloaded() => _reloadPending = true;
|
||||
|
||||
public async Task ReceiveAsync(byte[] datagram)
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
var packets = new List<NetPacket>();
|
||||
|
||||
try
|
||||
{
|
||||
_kcp.Input(datagram);
|
||||
_kcp.Update(Now);
|
||||
|
||||
while (_kcp.TryReceive(out var data))
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (NetPacket.TryRead(data.AsSpan(offset), out var packet, out var consumed))
|
||||
{
|
||||
packets.Add(packet);
|
||||
offset += consumed;
|
||||
}
|
||||
|
||||
if (offset != data.Length)
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: {Left} trailing bytes in kcp payload", Conversation, data.Length - offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
|
||||
if (_reloadPending && Player.LoggedIn)
|
||||
{
|
||||
_reloadPending = false;
|
||||
await this.OnSrToolsReloadedAsync();
|
||||
}
|
||||
|
||||
foreach (var packet in packets)
|
||||
{
|
||||
await DispatchAsync(packet);
|
||||
}
|
||||
|
||||
FlushPlayerState(force: false);
|
||||
await TickAsync();
|
||||
}
|
||||
|
||||
private async Task DispatchAsync(NetPacket packet)
|
||||
{
|
||||
if (packet.CmdId != HeartBeatCmdId && packet.CmdId != SceneEntityMoveCmdId)
|
||||
{
|
||||
_logger.LogInformation("conv {Conv}: <- {Name} ({Id}, {Bytes}b)",
|
||||
Conversation, packet.Name, packet.CmdId, packet.Body.Length);
|
||||
}
|
||||
|
||||
if (packet.CmdId == LogoutCmdId)
|
||||
{
|
||||
_logger.LogInformation("conv {Conv}: logout", Conversation);
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_handlers.TryGetHandler(packet.CmdId, out var handler))
|
||||
{
|
||||
var message = packet.ParseBody();
|
||||
|
||||
if (message is null)
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: could not parse {Name}", Conversation, packet.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await handler(this, message);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "conv {Conv}: {Name} handler threw", Conversation, packet.Name);
|
||||
await SendErrorResponseAsync(packet.CmdId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet.Name.EndsWith("Notify", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogDebug("conv {Conv}: ignoring notify {Name}", Conversation, packet.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ProtocolRegistry.TryGetResponseFor(packet.CmdId, out var responseCmdId))
|
||||
{
|
||||
// once per session is enough to know a feature is missing
|
||||
if (_warnedUnimplemented.Add(packet.CmdId))
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: {Name} not implemented - sending empty {Response}",
|
||||
Conversation, packet.Name, ProtocolRegistry.GetName(responseCmdId));
|
||||
}
|
||||
|
||||
await SendRawAsync(NetPacket.Empty(responseCmdId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_warnedUnimplemented.Add(packet.CmdId))
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: {Name} ({Id}) has no matching ScRsp in the dump",
|
||||
Conversation, packet.Name, packet.CmdId);
|
||||
}
|
||||
}
|
||||
|
||||
// a request that blew up still gets its response, flagged as a server error, so the
|
||||
// client shows a retry prompt instead of waiting on a spinner forever
|
||||
private async Task SendErrorResponseAsync(ushort requestCmdId)
|
||||
{
|
||||
if (!ProtocolRegistry.TryGetResponseFor(requestCmdId, out var responseCmdId) ||
|
||||
!ProtocolRegistry.TryGetDescriptor(responseCmdId, out var descriptor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var response = descriptor.Parser.ParseFrom(ReadOnlySpan<byte>.Empty);
|
||||
var retcode = descriptor.FindFieldByName("retcode");
|
||||
|
||||
if (retcode is not null && retcode.FieldType == Google.Protobuf.Reflection.FieldType.UInt32)
|
||||
{
|
||||
retcode.Accessor.SetValue(response, (uint)Retcode.RetServerInternalError);
|
||||
}
|
||||
|
||||
await SendAsync(response);
|
||||
}
|
||||
|
||||
public Task SendAsync(IMessage message) => SendRawAsync(NetPacket.Create(message));
|
||||
|
||||
public async Task SendRawAsync(NetPacket packet)
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
_kcp.Send(packet.ToArray());
|
||||
_kcp.Flush();
|
||||
_kcp.Update(Now);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
|
||||
if (packet.CmdId is not (SceneEntityMoveRspCmdId or HeartBeatRspCmdId))
|
||||
{
|
||||
_logger.LogDebug("conv {Conv}: -> {Name}", Conversation, packet.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task TickAsync()
|
||||
{
|
||||
await _gate.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
_kcp.Update(Now);
|
||||
|
||||
if (_kcp.IsDead)
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: kcp link dead", Conversation);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Close()
|
||||
{
|
||||
if (IsClosed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsClosed = true;
|
||||
World.PlayerReloaded -= OnPlayerReloaded;
|
||||
FlushPlayerState(force: true);
|
||||
}
|
||||
|
||||
private void FlushPlayerState(bool force)
|
||||
{
|
||||
if (!Config.GameServer.Persist || !Player.Dirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && DateTime.UtcNow - _lastSave < SaveInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PlayerStore.Save(World.PlayerStorePath, Player);
|
||||
Player.ClearDirty();
|
||||
_lastSave = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning("could not save player state: {Message}", e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendDatagram(ReadOnlySpan<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
_socket.SendTo(data, SocketFlags.None, _remote);
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
_logger.LogWarning("conv {Conv}: send failed ({Error})", Conversation, e.SocketErrorCode);
|
||||
IsClosed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
var config = ServerConfig.Load();
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole(o =>
|
||||
{
|
||||
o.SingleLine = true;
|
||||
o.TimestampFormat = "HH:mm:ss ";
|
||||
});
|
||||
builder.Logging.AddProvider(new FileLoggerProvider("game"));
|
||||
|
||||
builder.Services.AddSingleton(config);
|
||||
builder.Services.AddSingleton<KcpGateway>();
|
||||
|
||||
var host = builder.Build();
|
||||
var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("CapySR");
|
||||
|
||||
ProtocolRegistry.EnsureInitialized();
|
||||
logger.LogInformation("logging to {Path}", Path.Combine(RepoPaths.Root, "logs", "game.log"));
|
||||
logger.LogInformation("protocol: {Count} commands", ProtocolRegistry.Count);
|
||||
|
||||
var handlers = HandlerRegistry.Build(logger);
|
||||
logger.LogInformation("handlers: {Handlers} implemented", handlers.HandlerCount);
|
||||
|
||||
GameWorld world;
|
||||
|
||||
try
|
||||
{
|
||||
world = new GameWorld(config, logger);
|
||||
}
|
||||
catch (DirectoryNotFoundException e)
|
||||
{
|
||||
// a fresh clone has no game data yet; say so plainly instead of dumping a stack trace
|
||||
logger.LogError("{Message}", e.Message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var gateway = new KcpGateway(
|
||||
config,
|
||||
handlers,
|
||||
world,
|
||||
host.Services.GetRequiredService<ILoggerFactory>().CreateLogger<KcpGateway>());
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cancellation.Cancel();
|
||||
};
|
||||
|
||||
await gateway.RunAsync(cancellation.Token);
|
||||
|
||||
return 0;
|
||||
@@ -1,229 +0,0 @@
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Scene;
|
||||
|
||||
public sealed class SceneBuilder(GameData data)
|
||||
{
|
||||
public const uint AvatarEntityBase = 100_000;
|
||||
public const uint MonsterEntityBase = 200_000;
|
||||
|
||||
// the astral express is a special case: the client already has its props, and feeding
|
||||
// them back confuses it, so pearl-sr skips entity population for plane 10000 too
|
||||
private const uint TrainPlaneId = 10000;
|
||||
|
||||
public static uint ActorEntityId(uint baseAvatarId) => baseAvatarId + AvatarEntityBase;
|
||||
|
||||
// avatars land in group 0; the challenge encounter gets its own group so the client
|
||||
// can spawn and despawn it independently
|
||||
public SceneInfo BuildChallengeScene(ChallengeState challenge)
|
||||
{
|
||||
var scene = new SceneInfo
|
||||
{
|
||||
GameModeType = 1,
|
||||
PlaneId = challenge.PlaneId,
|
||||
FloorId = challenge.FloorId,
|
||||
EntryId = challenge.EntryId,
|
||||
WorldId = challenge.WorldId,
|
||||
ClientPosVersion = 1,
|
||||
SceneIdentifier = new SceneIdentifier { FloorId = challenge.FloorId },
|
||||
};
|
||||
|
||||
var spawn = data.FindAnchor(challenge.EntryId);
|
||||
var motion = Motion(spawn?.Pos, spawn?.Rot);
|
||||
var avatars = new SceneEntityGroupInfo { State = 1, GroupId = 0 };
|
||||
|
||||
foreach (var avatarId in challenge.Lineup)
|
||||
{
|
||||
avatars.EntityList.Add(BuildActor(avatarId, motion));
|
||||
}
|
||||
|
||||
if (avatars.EntityList.Count > 0)
|
||||
{
|
||||
scene.LeaderEntityId = avatars.EntityList[0].EntityId;
|
||||
}
|
||||
|
||||
scene.EntityGroupList.Add(avatars);
|
||||
|
||||
var placement = data.FindMonsterPlacement(challenge.PlaneId, challenge.GroupId);
|
||||
|
||||
var monsters = new SceneEntityGroupInfo { State = 1, GroupId = challenge.GroupId };
|
||||
monsters.EntityList.Add(new SceneEntityInfo
|
||||
{
|
||||
InstId = placement?.InstId ?? 1,
|
||||
GroupId = challenge.GroupId,
|
||||
EntityId = MonsterEntityBase + 1,
|
||||
NpcMonster = new SceneNpcMonsterInfo
|
||||
{
|
||||
MonsterId = challenge.MonsterId,
|
||||
EventId = challenge.EventId,
|
||||
WorldLevel = 6,
|
||||
},
|
||||
Motion = Motion(placement?.Pos, placement?.Rot),
|
||||
});
|
||||
|
||||
scene.EntityGroupList.Add(monsters);
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
public SceneInfo BuildWorldScene(
|
||||
uint entryId, IReadOnlyList<uint> lineup, uint teleportId = 0, uint worldId = 501, PlayerPosition? spawn = null)
|
||||
{
|
||||
var resolved = data.ResolveEntrance(entryId);
|
||||
|
||||
var planeId = resolved?.Entrance.PlaneID ?? 0;
|
||||
var floorId = resolved?.Entrance.FloorID ?? 0;
|
||||
|
||||
var scene = new SceneInfo
|
||||
{
|
||||
GameModeType = 1,
|
||||
PlaneId = planeId,
|
||||
FloorId = floorId,
|
||||
EntryId = entryId,
|
||||
WorldId = worldId,
|
||||
ClientPosVersion = 1,
|
||||
SceneIdentifier = new SceneIdentifier { FloorId = floorId },
|
||||
};
|
||||
|
||||
if (planeId != TrainPlaneId && data.FloorSavedValues.TryGetValue(floorId, out var saved))
|
||||
{
|
||||
foreach (var value in saved.SavedValues.Where(v => v.Name.Length > 0))
|
||||
{
|
||||
scene.FloorSavedData[value.Name] = value.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
var motion = SpawnMotion(entryId, teleportId, spawn);
|
||||
var avatars = new SceneEntityGroupInfo { State = 1, GroupId = 0 };
|
||||
|
||||
foreach (var avatarId in lineup.Where(id => id != 0))
|
||||
{
|
||||
avatars.EntityList.Add(BuildActor(avatarId, motion));
|
||||
}
|
||||
|
||||
if (avatars.EntityList.Count > 0)
|
||||
{
|
||||
scene.LeaderEntityId = avatars.EntityList[0].EntityId;
|
||||
}
|
||||
|
||||
scene.EntityGroupList.Add(avatars);
|
||||
|
||||
var resource = data.GetScene(entryId);
|
||||
|
||||
if (planeId != TrainPlaneId && resource is not null)
|
||||
{
|
||||
AddEnvironment(scene, resource);
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
// a teleport the player picked wins; otherwise where they last stood; otherwise the anchor
|
||||
public MotionInfo SpawnMotion(uint entryId, uint teleportId, PlayerPosition? spawn)
|
||||
{
|
||||
if (data.FindTeleport(entryId, teleportId) is { } teleport)
|
||||
{
|
||||
return Motion(teleport.Pos, teleport.Rot);
|
||||
}
|
||||
|
||||
if (spawn is not null)
|
||||
{
|
||||
return spawn.ToMotion();
|
||||
}
|
||||
|
||||
if (data.FindAnchor(entryId) is { } anchor)
|
||||
{
|
||||
return Motion(anchor.Pos, anchor.Rot);
|
||||
}
|
||||
|
||||
var fallback = data.GetScene(entryId)?.Teleports.FirstOrDefault();
|
||||
return Motion(fallback?.Pos, fallback?.Rot);
|
||||
}
|
||||
|
||||
public SceneEntityInfo BuildActor(uint avatarId, MotionInfo motion)
|
||||
{
|
||||
var baseId = data.BaseAvatarId(avatarId);
|
||||
|
||||
return new SceneEntityInfo
|
||||
{
|
||||
InstId = 1,
|
||||
EntityId = ActorEntityId(baseId),
|
||||
Actor = new SceneActorInfo
|
||||
{
|
||||
BaseAvatarId = baseId,
|
||||
AvatarType = AvatarType.AvatarFormalType,
|
||||
Uid = 1,
|
||||
MapLayer = 0,
|
||||
},
|
||||
Motion = motion.Clone(),
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddEnvironment(SceneInfo scene, SceneResourceExcel resource)
|
||||
{
|
||||
var groups = new Dictionary<uint, SceneEntityGroupInfo>();
|
||||
var nextEntityId = MonsterEntityBase;
|
||||
|
||||
SceneEntityGroupInfo Group(uint groupId)
|
||||
{
|
||||
if (!groups.TryGetValue(groupId, out var group))
|
||||
{
|
||||
groups[groupId] = group = new SceneEntityGroupInfo { State = 1, GroupId = groupId };
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
foreach (var prop in resource.Props)
|
||||
{
|
||||
Group(prop.GroupId).EntityList.Add(new SceneEntityInfo
|
||||
{
|
||||
InstId = prop.InstId,
|
||||
GroupId = prop.GroupId,
|
||||
EntityId = ++nextEntityId,
|
||||
Prop = new ScenePropInfo { PropId = prop.PropId, PropState = prop.PropState },
|
||||
Motion = Motion(prop.Pos, prop.Rot),
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var monster in resource.Monsters)
|
||||
{
|
||||
Group(monster.GroupId).EntityList.Add(new SceneEntityInfo
|
||||
{
|
||||
InstId = monster.InstId,
|
||||
GroupId = monster.GroupId,
|
||||
EntityId = ++nextEntityId,
|
||||
NpcMonster = new SceneNpcMonsterInfo
|
||||
{
|
||||
MonsterId = monster.MonsterId,
|
||||
EventId = monster.EventId,
|
||||
WorldLevel = 6,
|
||||
},
|
||||
Motion = Motion(monster.Pos, monster.Rot),
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var group in groups.Values)
|
||||
{
|
||||
scene.EntityGroupList.Add(group);
|
||||
}
|
||||
}
|
||||
|
||||
public MotionInfo? AnchorMotion(uint entryId)
|
||||
{
|
||||
var anchor = data.FindAnchor(entryId);
|
||||
return anchor is null ? null : Motion(anchor.Pos, anchor.Rot);
|
||||
}
|
||||
|
||||
public static MotionInfo Motion(ResVector? pos, ResVector? rot) => new()
|
||||
{
|
||||
Pos = Vector(pos),
|
||||
Rot = Vector(rot),
|
||||
};
|
||||
|
||||
private static Protocol.Vector Vector(ResVector? v) =>
|
||||
v is null ? new Protocol.Vector() : new Protocol.Vector { X = v.X, Y = v.Y, Z = v.Z };
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
using CapySR.Protocol;
|
||||
|
||||
namespace CapySR.GameServer.Scene;
|
||||
|
||||
public enum SceneEntityKind
|
||||
{
|
||||
Actor,
|
||||
Monster,
|
||||
Prop,
|
||||
Npc,
|
||||
}
|
||||
|
||||
public sealed record SceneEntity(
|
||||
uint EntityId,
|
||||
SceneEntityKind Kind,
|
||||
uint GroupId,
|
||||
uint InstId,
|
||||
uint AvatarId,
|
||||
uint MonsterId,
|
||||
uint EventId,
|
||||
uint PropId,
|
||||
uint PropState,
|
||||
MotionInfo? Motion);
|
||||
|
||||
// what the client currently has loaded, by entity id, so a cast skill can be resolved to
|
||||
// the monsters it hit and a won fight can take them off the map
|
||||
public sealed class SceneState
|
||||
{
|
||||
private readonly Dictionary<uint, SceneEntity> _entities = [];
|
||||
|
||||
public SceneInfo? Info { get; private set; }
|
||||
|
||||
public uint EntryId => Info?.EntryId ?? 0;
|
||||
|
||||
public uint FloorId => Info?.FloorId ?? 0;
|
||||
|
||||
public uint PlaneId => Info?.PlaneId ?? 0;
|
||||
|
||||
public bool IsChallenge { get; private set; }
|
||||
|
||||
public IReadOnlyDictionary<uint, SceneEntity> Entities => _entities;
|
||||
|
||||
public IEnumerable<SceneEntity> Monsters => _entities.Values.Where(e => e.Kind == SceneEntityKind.Monster);
|
||||
|
||||
public IEnumerable<SceneEntity> Actors => _entities.Values.Where(e => e.Kind == SceneEntityKind.Actor);
|
||||
|
||||
public void Load(SceneInfo info, bool isChallenge)
|
||||
{
|
||||
Info = info;
|
||||
IsChallenge = isChallenge;
|
||||
_entities.Clear();
|
||||
|
||||
foreach (var group in info.EntityGroupList)
|
||||
{
|
||||
foreach (var entity in group.EntityList)
|
||||
{
|
||||
Register(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Info = null;
|
||||
IsChallenge = false;
|
||||
_entities.Clear();
|
||||
}
|
||||
|
||||
public SceneEntity? Get(uint entityId) => _entities.GetValueOrDefault(entityId);
|
||||
|
||||
public bool IsActor(uint entityId) => Get(entityId)?.Kind == SceneEntityKind.Actor;
|
||||
|
||||
public bool IsMonster(uint entityId) => Get(entityId)?.Kind == SceneEntityKind.Monster;
|
||||
|
||||
public IEnumerable<SceneEntity> MonstersAmong(IEnumerable<uint> entityIds) =>
|
||||
entityIds.Distinct().Select(Get).OfType<SceneEntity>().Where(e => e.Kind == SceneEntityKind.Monster);
|
||||
|
||||
public IEnumerable<SceneEntity> PropsAmong(IEnumerable<uint> entityIds) =>
|
||||
entityIds.Distinct().Select(Get).OfType<SceneEntity>().Where(e => e.Kind == SceneEntityKind.Prop);
|
||||
|
||||
public bool Remove(uint entityId)
|
||||
{
|
||||
if (!_entities.Remove(entityId) || Info is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var group in Info.EntityGroupList)
|
||||
{
|
||||
var index = group.EntityList.ToList().FindIndex(e => e.EntityId == entityId);
|
||||
if (index >= 0)
|
||||
{
|
||||
group.EntityList.RemoveAt(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// the party lives in group 0; swap it wholesale when the lineup changes
|
||||
public void ReplaceActors(IEnumerable<SceneEntityInfo> actors, uint leaderEntityId)
|
||||
{
|
||||
if (Info is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in Actors.Select(a => a.EntityId).ToList())
|
||||
{
|
||||
_entities.Remove(id);
|
||||
}
|
||||
|
||||
var group = Info.EntityGroupList.FirstOrDefault(g => g.GroupId == 0);
|
||||
if (group is null)
|
||||
{
|
||||
group = new SceneEntityGroupInfo { State = 1, GroupId = 0 };
|
||||
Info.EntityGroupList.Insert(0, group);
|
||||
}
|
||||
|
||||
group.EntityList.Clear();
|
||||
|
||||
foreach (var actor in actors)
|
||||
{
|
||||
group.EntityList.Add(actor);
|
||||
Register(actor);
|
||||
}
|
||||
|
||||
Info.LeaderEntityId = leaderEntityId;
|
||||
}
|
||||
|
||||
private void Register(SceneEntityInfo entity)
|
||||
{
|
||||
var record = entity.EntityCase switch
|
||||
{
|
||||
SceneEntityInfo.EntityOneofCase.Actor => new SceneEntity(
|
||||
entity.EntityId, SceneEntityKind.Actor, entity.GroupId, entity.InstId,
|
||||
entity.Actor.BaseAvatarId, 0, 0, 0, 0, entity.Motion),
|
||||
|
||||
SceneEntityInfo.EntityOneofCase.NpcMonster => new SceneEntity(
|
||||
entity.EntityId, SceneEntityKind.Monster, entity.GroupId, entity.InstId,
|
||||
0, entity.NpcMonster.MonsterId, entity.NpcMonster.EventId, 0, 0, entity.Motion),
|
||||
|
||||
SceneEntityInfo.EntityOneofCase.Prop => new SceneEntity(
|
||||
entity.EntityId, SceneEntityKind.Prop, entity.GroupId, entity.InstId,
|
||||
0, 0, 0, entity.Prop.PropId, entity.Prop.PropState, entity.Motion),
|
||||
|
||||
SceneEntityInfo.EntityOneofCase.Npc => new SceneEntity(
|
||||
entity.EntityId, SceneEntityKind.Npc, entity.GroupId, entity.InstId,
|
||||
0, 0, 0, 0, 0, entity.Motion),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (record is not null)
|
||||
{
|
||||
_entities[record.EntityId] = record;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" />
|
||||
@@ -1,763 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace CapySR.Kcp;
|
||||
|
||||
public delegate void KcpOutput(ReadOnlySpan<byte> data);
|
||||
|
||||
// ikcp port with mihoyo's 28-byte header (conv + token prefix), little-endian.
|
||||
public sealed class Kcp
|
||||
{
|
||||
public const int Overhead = 28;
|
||||
|
||||
private const uint RtoNoDelay = 30;
|
||||
private const uint RtoMin = 100;
|
||||
private const uint RtoDefault = 200;
|
||||
private const uint RtoMax = 60000;
|
||||
|
||||
private const byte CmdPush = 81;
|
||||
private const byte CmdAck = 82;
|
||||
private const byte CmdWindowAsk = 83;
|
||||
private const byte CmdWindowTell = 84;
|
||||
|
||||
private const uint AskSend = 1;
|
||||
private const uint AskTell = 2;
|
||||
|
||||
private const int WndSend = 32;
|
||||
private const int WndRecv = 258;
|
||||
private const int MtuDefault = 1400;
|
||||
private const int Interval = 100;
|
||||
private const uint DeadLink = 20;
|
||||
private const uint ThreshInit = 2;
|
||||
private const uint ThreshMin = 2;
|
||||
private const uint ProbeInit = 7000;
|
||||
private const uint ProbeLimit = 120000;
|
||||
private const uint FastAckLimit = 5;
|
||||
|
||||
private readonly KcpOutput _output;
|
||||
private readonly List<Segment> _sendBuffer = [];
|
||||
private readonly List<Segment> _receiveBuffer = [];
|
||||
private readonly Queue<Segment> _sendQueue = new();
|
||||
private readonly Queue<Segment> _receiveQueue = new();
|
||||
private readonly List<(uint Sn, uint Ts)> _ackList = [];
|
||||
|
||||
private readonly byte[] _flushBuffer;
|
||||
|
||||
private uint _sendUna;
|
||||
private uint _sendNext;
|
||||
private uint _receiveNext;
|
||||
|
||||
private uint _sendWindow = WndSend;
|
||||
private uint _receiveWindow = WndRecv;
|
||||
private uint _remoteWindow = WndRecv;
|
||||
private uint _congestionWindow;
|
||||
private uint _increment;
|
||||
|
||||
private uint _probe;
|
||||
private uint _probeTimestamp;
|
||||
private uint _probeWait;
|
||||
|
||||
private int _mtu = MtuDefault;
|
||||
private int _mss = MtuDefault - Overhead;
|
||||
|
||||
private uint _rttSmoothed;
|
||||
private uint _rttVariance;
|
||||
private uint _rto = RtoDefault;
|
||||
private uint _minRto = RtoMin;
|
||||
|
||||
private uint _current;
|
||||
private uint _interval = Interval;
|
||||
private uint _flushTimestamp = Interval;
|
||||
private bool _noDelay;
|
||||
private bool _updated;
|
||||
|
||||
private uint _ssthresh = ThreshInit;
|
||||
private uint _fastResend;
|
||||
private uint _fastLimit = FastAckLimit;
|
||||
private bool _noCongestionWindow;
|
||||
private uint _transmissions;
|
||||
private uint _deadLink = DeadLink;
|
||||
|
||||
public Kcp(uint conversation, uint token, KcpOutput output)
|
||||
{
|
||||
Conversation = conversation;
|
||||
Token = token;
|
||||
_output = output;
|
||||
_flushBuffer = new byte[(MtuDefault + Overhead) * 3];
|
||||
}
|
||||
|
||||
public uint Conversation { get; }
|
||||
|
||||
public uint Token { get; }
|
||||
|
||||
public bool IsDead { get; private set; }
|
||||
|
||||
public int WaitingToSend => _sendBuffer.Count + _sendQueue.Count;
|
||||
|
||||
public static uint GetConversation(ReadOnlySpan<byte> datagram) =>
|
||||
datagram.Length >= Overhead ? BinaryPrimitives.ReadUInt32LittleEndian(datagram) : 0;
|
||||
|
||||
public void SetNoDelay(bool noDelay, int interval, int resend, bool noCongestionWindow)
|
||||
{
|
||||
_noDelay = noDelay;
|
||||
_minRto = noDelay ? RtoNoDelay : RtoMin;
|
||||
_interval = (uint)Math.Clamp(interval, 10, 5000);
|
||||
_fastResend = resend > 0 ? (uint)resend : 0;
|
||||
_noCongestionWindow = noCongestionWindow;
|
||||
}
|
||||
|
||||
public void SetWindowSize(int send, int receive)
|
||||
{
|
||||
_sendWindow = (uint)Math.Max(1, send);
|
||||
_receiveWindow = (uint)Math.Max(WndRecv, receive);
|
||||
}
|
||||
|
||||
// splits into mss-sized fragments, numbered high to low
|
||||
public void Send(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var count = data.Length <= _mss ? 1 : (data.Length + _mss - 1) / _mss;
|
||||
if (count > 255)
|
||||
{
|
||||
throw new ArgumentException($"payload of {data.Length} bytes needs {count} fragments (max 255)", nameof(data));
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var size = Math.Min(data.Length, _mss);
|
||||
_sendQueue.Enqueue(new Segment(data[..size].ToArray()) { Fragment = (byte)(count - i - 1) });
|
||||
data = data[size..];
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReceive(out byte[] data)
|
||||
{
|
||||
data = [];
|
||||
|
||||
if (_receiveQueue.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var size = PeekSize();
|
||||
if (size < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var recover = _receiveQueue.Count >= _receiveWindow;
|
||||
|
||||
data = new byte[size];
|
||||
var offset = 0;
|
||||
|
||||
while (_receiveQueue.Count > 0)
|
||||
{
|
||||
var segment = _receiveQueue.Dequeue();
|
||||
segment.Data.CopyTo(data, offset);
|
||||
offset += segment.Data.Length;
|
||||
|
||||
if (segment.Fragment == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MoveToReceiveQueue();
|
||||
|
||||
// window reopened, tell the peer
|
||||
if (_receiveQueue.Count < _receiveWindow && recover)
|
||||
{
|
||||
_probe |= AskTell;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Input(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
if (datagram.Length < Overhead)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previousUna = _sendUna;
|
||||
uint maxAck = 0;
|
||||
uint latestTs = 0;
|
||||
var sawAck = false;
|
||||
|
||||
while (datagram.Length >= Overhead)
|
||||
{
|
||||
var conversation = BinaryPrimitives.ReadUInt32LittleEndian(datagram);
|
||||
if (conversation != Conversation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cmd = datagram[8];
|
||||
var fragment = datagram[9];
|
||||
var window = BinaryPrimitives.ReadUInt16LittleEndian(datagram[10..]);
|
||||
var timestamp = BinaryPrimitives.ReadUInt32LittleEndian(datagram[12..]);
|
||||
var sn = BinaryPrimitives.ReadUInt32LittleEndian(datagram[16..]);
|
||||
var una = BinaryPrimitives.ReadUInt32LittleEndian(datagram[20..]);
|
||||
var length = BinaryPrimitives.ReadUInt32LittleEndian(datagram[24..]);
|
||||
|
||||
datagram = datagram[Overhead..];
|
||||
|
||||
if (length > (uint)datagram.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_remoteWindow = window;
|
||||
RemoveAcknowledged(una);
|
||||
ShrinkSendBuffer();
|
||||
|
||||
switch (cmd)
|
||||
{
|
||||
case CmdAck:
|
||||
if (TimeDiff(_current, timestamp) >= 0)
|
||||
{
|
||||
UpdateRto((uint)TimeDiff(_current, timestamp));
|
||||
}
|
||||
|
||||
AcknowledgeOne(sn);
|
||||
ShrinkSendBuffer();
|
||||
|
||||
if (!sawAck || TimeDiff(sn, maxAck) > 0)
|
||||
{
|
||||
sawAck = true;
|
||||
maxAck = sn;
|
||||
latestTs = timestamp;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case CmdPush:
|
||||
if (TimeDiff(sn, _receiveNext + _receiveWindow) < 0)
|
||||
{
|
||||
_ackList.Add((sn, timestamp));
|
||||
|
||||
if (TimeDiff(sn, _receiveNext) >= 0)
|
||||
{
|
||||
InsertReceived(new Segment(datagram[..(int)length].ToArray())
|
||||
{
|
||||
Conversation = conversation,
|
||||
Command = cmd,
|
||||
Fragment = fragment,
|
||||
Window = window,
|
||||
Timestamp = timestamp,
|
||||
SequenceNumber = sn,
|
||||
Una = una,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case CmdWindowAsk:
|
||||
_probe |= AskTell;
|
||||
break;
|
||||
|
||||
case CmdWindowTell:
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
datagram = datagram[(int)length..];
|
||||
}
|
||||
|
||||
if (sawAck)
|
||||
{
|
||||
AcknowledgeFast(maxAck, latestTs);
|
||||
}
|
||||
|
||||
// ack advanced our window, so grow the congestion window
|
||||
if (TimeDiff(_sendUna, previousUna) > 0 && _congestionWindow < _remoteWindow)
|
||||
{
|
||||
var mss = (uint)_mss;
|
||||
|
||||
if (_congestionWindow < _ssthresh)
|
||||
{
|
||||
_congestionWindow++;
|
||||
_increment += mss;
|
||||
}
|
||||
else
|
||||
{
|
||||
_increment = Math.Max(_increment, mss);
|
||||
_increment += mss * mss / _increment + mss / 16;
|
||||
|
||||
if ((_congestionWindow + 1) * mss <= _increment)
|
||||
{
|
||||
_congestionWindow = (_increment + mss - 1) / mss;
|
||||
}
|
||||
}
|
||||
|
||||
if (_congestionWindow > _remoteWindow)
|
||||
{
|
||||
_congestionWindow = _remoteWindow;
|
||||
_increment = _remoteWindow * mss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(uint currentMs)
|
||||
{
|
||||
_current = currentMs;
|
||||
|
||||
if (!_updated)
|
||||
{
|
||||
_updated = true;
|
||||
_flushTimestamp = _current;
|
||||
}
|
||||
|
||||
var slap = TimeDiff(_current, _flushTimestamp);
|
||||
|
||||
if (slap is >= 10000 or < -10000)
|
||||
{
|
||||
_flushTimestamp = _current;
|
||||
slap = 0;
|
||||
}
|
||||
|
||||
if (slap >= 0)
|
||||
{
|
||||
_flushTimestamp += _interval;
|
||||
|
||||
if (TimeDiff(_current, _flushTimestamp) >= 0)
|
||||
{
|
||||
_flushTimestamp = _current + _interval;
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
}
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
if (!_updated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
var window = (ushort)Math.Max(0, (int)_receiveWindow - _receiveQueue.Count);
|
||||
|
||||
var template = new Segment([])
|
||||
{
|
||||
Conversation = Conversation,
|
||||
Command = CmdAck,
|
||||
Window = window,
|
||||
Una = _receiveNext,
|
||||
};
|
||||
|
||||
foreach (var (sn, ts) in _ackList)
|
||||
{
|
||||
FlushIfFull(ref offset, 0);
|
||||
template.SequenceNumber = sn;
|
||||
template.Timestamp = ts;
|
||||
WriteHeader(template, _flushBuffer.AsSpan(offset), 0);
|
||||
offset += Overhead;
|
||||
}
|
||||
|
||||
_ackList.Clear();
|
||||
|
||||
ProbeWindow();
|
||||
|
||||
if ((_probe & AskSend) != 0)
|
||||
{
|
||||
FlushIfFull(ref offset, 0);
|
||||
template.Command = CmdWindowAsk;
|
||||
template.SequenceNumber = 0;
|
||||
template.Timestamp = 0;
|
||||
WriteHeader(template, _flushBuffer.AsSpan(offset), 0);
|
||||
offset += Overhead;
|
||||
}
|
||||
|
||||
if ((_probe & AskTell) != 0)
|
||||
{
|
||||
FlushIfFull(ref offset, 0);
|
||||
template.Command = CmdWindowTell;
|
||||
template.SequenceNumber = 0;
|
||||
template.Timestamp = 0;
|
||||
WriteHeader(template, _flushBuffer.AsSpan(offset), 0);
|
||||
offset += Overhead;
|
||||
}
|
||||
|
||||
_probe = 0;
|
||||
|
||||
var congestionWindow = Math.Min(_sendWindow, _remoteWindow);
|
||||
if (!_noCongestionWindow)
|
||||
{
|
||||
congestionWindow = Math.Min(congestionWindow, _congestionWindow);
|
||||
}
|
||||
|
||||
while (_sendQueue.Count > 0 && TimeDiff(_sendNext, _sendUna + congestionWindow) < 0)
|
||||
{
|
||||
var segment = _sendQueue.Dequeue();
|
||||
segment.Conversation = Conversation;
|
||||
segment.Command = CmdPush;
|
||||
segment.Window = window;
|
||||
segment.Timestamp = _current;
|
||||
segment.SequenceNumber = _sendNext++;
|
||||
segment.Una = _receiveNext;
|
||||
segment.ResendTimestamp = _current;
|
||||
segment.Rto = _rto;
|
||||
_sendBuffer.Add(segment);
|
||||
}
|
||||
|
||||
var resent = _fastResend > 0 ? _fastResend : uint.MaxValue;
|
||||
var minRto = _noDelay ? 0u : _rto >> 3;
|
||||
var lost = false;
|
||||
var fastRetransmit = false;
|
||||
|
||||
foreach (var segment in _sendBuffer)
|
||||
{
|
||||
var send = false;
|
||||
|
||||
if (segment.Transmissions == 0)
|
||||
{
|
||||
send = true;
|
||||
segment.Transmissions++;
|
||||
segment.Rto = _rto;
|
||||
segment.ResendTimestamp = _current + segment.Rto + minRto;
|
||||
}
|
||||
else if (TimeDiff(_current, segment.ResendTimestamp) >= 0)
|
||||
{
|
||||
send = true;
|
||||
segment.Transmissions++;
|
||||
_transmissions++;
|
||||
|
||||
segment.Rto = _noDelay
|
||||
? segment.Rto + Math.Max(segment.Rto, _rto) / 2
|
||||
: segment.Rto + Math.Max(segment.Rto, _rto);
|
||||
|
||||
segment.Rto = Math.Min(segment.Rto, RtoMax);
|
||||
segment.ResendTimestamp = _current + segment.Rto;
|
||||
lost = true;
|
||||
}
|
||||
else if (segment.FastAck >= resent &&
|
||||
(segment.Transmissions <= _fastLimit || _fastLimit == 0))
|
||||
{
|
||||
send = true;
|
||||
segment.Transmissions++;
|
||||
segment.FastAck = 0;
|
||||
segment.ResendTimestamp = _current + segment.Rto;
|
||||
fastRetransmit = true;
|
||||
}
|
||||
|
||||
if (!send)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
segment.Timestamp = _current;
|
||||
segment.Window = window;
|
||||
segment.Una = _receiveNext;
|
||||
|
||||
FlushIfFull(ref offset, segment.Data.Length);
|
||||
WriteHeader(segment, _flushBuffer.AsSpan(offset), segment.Data.Length);
|
||||
segment.Data.CopyTo(_flushBuffer, offset + Overhead);
|
||||
offset += Overhead + segment.Data.Length;
|
||||
|
||||
if (segment.Transmissions >= _deadLink)
|
||||
{
|
||||
IsDead = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (offset > 0)
|
||||
{
|
||||
_output(_flushBuffer.AsSpan(0, offset));
|
||||
}
|
||||
|
||||
if (fastRetransmit)
|
||||
{
|
||||
var inflight = _sendNext - _sendUna;
|
||||
_ssthresh = Math.Max(inflight / 2, ThreshMin);
|
||||
_congestionWindow = _ssthresh + resent;
|
||||
_increment = _congestionWindow * (uint)_mss;
|
||||
}
|
||||
|
||||
if (lost)
|
||||
{
|
||||
_ssthresh = Math.Max(congestionWindow / 2, ThreshMin);
|
||||
_congestionWindow = 1;
|
||||
_increment = (uint)_mss;
|
||||
}
|
||||
|
||||
if (_congestionWindow < 1)
|
||||
{
|
||||
_congestionWindow = 1;
|
||||
_increment = (uint)_mss;
|
||||
}
|
||||
}
|
||||
|
||||
public uint Check(uint currentMs)
|
||||
{
|
||||
if (!_updated)
|
||||
{
|
||||
return currentMs;
|
||||
}
|
||||
|
||||
var flushTimestamp = _flushTimestamp;
|
||||
|
||||
if (TimeDiff(currentMs, flushTimestamp) is >= 10000 or < -10000)
|
||||
{
|
||||
flushTimestamp = currentMs;
|
||||
}
|
||||
|
||||
if (TimeDiff(currentMs, flushTimestamp) >= 0)
|
||||
{
|
||||
return currentMs;
|
||||
}
|
||||
|
||||
var next = (uint)TimeDiff(flushTimestamp, currentMs);
|
||||
var minimum = next;
|
||||
|
||||
foreach (var segment in _sendBuffer)
|
||||
{
|
||||
var diff = TimeDiff(segment.ResendTimestamp, currentMs);
|
||||
if (diff <= 0)
|
||||
{
|
||||
return currentMs;
|
||||
}
|
||||
|
||||
minimum = Math.Min(minimum, (uint)diff);
|
||||
}
|
||||
|
||||
return currentMs + Math.Min(minimum, _interval);
|
||||
}
|
||||
|
||||
private void FlushIfFull(ref int offset, int payload)
|
||||
{
|
||||
if (offset + Overhead + payload > _mtu)
|
||||
{
|
||||
_output(_flushBuffer.AsSpan(0, offset));
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHeader(Segment segment, Span<byte> destination, int length)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination, Conversation);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[4..], Token);
|
||||
destination[8] = segment.Command;
|
||||
destination[9] = segment.Fragment;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination[10..], segment.Window);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[12..], segment.Timestamp);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[16..], segment.SequenceNumber);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[20..], segment.Una);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination[24..], (uint)length);
|
||||
}
|
||||
|
||||
private int PeekSize()
|
||||
{
|
||||
if (_receiveQueue.Count == 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var first = _receiveQueue.Peek();
|
||||
if (first.Fragment == 0)
|
||||
{
|
||||
return first.Data.Length;
|
||||
}
|
||||
|
||||
if (_receiveQueue.Count <= first.Fragment)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var length = 0;
|
||||
|
||||
foreach (var segment in _receiveQueue)
|
||||
{
|
||||
length += segment.Data.Length;
|
||||
|
||||
if (segment.Fragment == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private void InsertReceived(Segment segment)
|
||||
{
|
||||
var index = _receiveBuffer.Count;
|
||||
var duplicate = false;
|
||||
|
||||
for (var i = _receiveBuffer.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var existing = _receiveBuffer[i];
|
||||
|
||||
if (existing.SequenceNumber == segment.SequenceNumber)
|
||||
{
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (TimeDiff(segment.SequenceNumber, existing.SequenceNumber) > 0)
|
||||
{
|
||||
index = i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
index = i;
|
||||
}
|
||||
|
||||
if (!duplicate)
|
||||
{
|
||||
_receiveBuffer.Insert(index, segment);
|
||||
}
|
||||
|
||||
MoveToReceiveQueue();
|
||||
}
|
||||
|
||||
private void MoveToReceiveQueue()
|
||||
{
|
||||
while (_receiveBuffer.Count > 0)
|
||||
{
|
||||
var first = _receiveBuffer[0];
|
||||
|
||||
if (first.SequenceNumber != _receiveNext || _receiveQueue.Count >= _receiveWindow)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_receiveBuffer.RemoveAt(0);
|
||||
_receiveQueue.Enqueue(first);
|
||||
_receiveNext++;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveAcknowledged(uint una)
|
||||
{
|
||||
_sendBuffer.RemoveAll(s => TimeDiff(una, s.SequenceNumber) > 0);
|
||||
}
|
||||
|
||||
private void AcknowledgeOne(uint sn)
|
||||
{
|
||||
if (TimeDiff(sn, _sendUna) < 0 || TimeDiff(sn, _sendNext) >= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = _sendBuffer.FindIndex(s => s.SequenceNumber == sn);
|
||||
if (index >= 0)
|
||||
{
|
||||
_sendBuffer.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
private void AcknowledgeFast(uint sn, uint timestamp)
|
||||
{
|
||||
if (TimeDiff(sn, _sendUna) < 0 || TimeDiff(sn, _sendNext) >= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var segment in _sendBuffer)
|
||||
{
|
||||
if (TimeDiff(sn, segment.SequenceNumber) < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (sn != segment.SequenceNumber && TimeDiff(timestamp, segment.Timestamp) >= 0)
|
||||
{
|
||||
segment.FastAck++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShrinkSendBuffer()
|
||||
{
|
||||
_sendUna = _sendBuffer.Count > 0 ? _sendBuffer[0].SequenceNumber : _sendNext;
|
||||
}
|
||||
|
||||
private void UpdateRto(uint rtt)
|
||||
{
|
||||
if (_rttSmoothed == 0)
|
||||
{
|
||||
_rttSmoothed = rtt;
|
||||
_rttVariance = rtt / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
var delta = rtt > _rttSmoothed ? rtt - _rttSmoothed : _rttSmoothed - rtt;
|
||||
_rttVariance = (3 * _rttVariance + delta) / 4;
|
||||
_rttSmoothed = (7 * _rttSmoothed + rtt) / 8;
|
||||
|
||||
if (_rttSmoothed < 1)
|
||||
{
|
||||
_rttSmoothed = 1;
|
||||
}
|
||||
}
|
||||
|
||||
var rto = _rttSmoothed + Math.Max(_interval, 4 * _rttVariance);
|
||||
_rto = (uint)Math.Clamp(rto, _minRto, RtoMax);
|
||||
}
|
||||
|
||||
private void ProbeWindow()
|
||||
{
|
||||
if (_remoteWindow != 0)
|
||||
{
|
||||
_probeTimestamp = 0;
|
||||
_probeWait = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_probeWait == 0)
|
||||
{
|
||||
_probeWait = ProbeInit;
|
||||
_probeTimestamp = _current + _probeWait;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TimeDiff(_current, _probeTimestamp) < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_probeWait = Math.Max(_probeWait, ProbeInit);
|
||||
_probeWait += _probeWait / 2;
|
||||
_probeWait = Math.Min(_probeWait, ProbeLimit);
|
||||
_probeTimestamp = _current + _probeWait;
|
||||
_probe |= AskSend;
|
||||
}
|
||||
|
||||
private static int TimeDiff(uint later, uint earlier) => (int)(later - earlier);
|
||||
|
||||
private sealed class Segment(byte[] data)
|
||||
{
|
||||
public byte[] Data { get; } = data;
|
||||
|
||||
public uint Conversation { get; set; }
|
||||
|
||||
public byte Command { get; set; }
|
||||
|
||||
public byte Fragment { get; set; }
|
||||
|
||||
public ushort Window { get; set; }
|
||||
|
||||
public uint Timestamp { get; set; }
|
||||
|
||||
public uint SequenceNumber { get; set; }
|
||||
|
||||
public uint Una { get; set; }
|
||||
|
||||
public uint ResendTimestamp { get; set; }
|
||||
|
||||
public uint Rto { get; set; }
|
||||
|
||||
public uint FastAck { get; set; }
|
||||
|
||||
public uint Transmissions { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- The generated protobuf surface is ~4.7k message types; nobody is reading its docs. -->
|
||||
<NoWarn>$(NoWarn);CS0612;CS0618;CS1591;CS8981</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.33.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Proto\StarRail.proto" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,936 +0,0 @@
|
||||
// <auto-generated>
|
||||
// generated by tools/CapySR.ProtoGen - do not edit
|
||||
// </auto-generated>
|
||||
|
||||
namespace CapySR.Protocol;
|
||||
|
||||
public static class CmdIdTable
|
||||
{
|
||||
public static ReadOnlySpan<ushort> Ids =>
|
||||
[
|
||||
4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 23, 25, 26, 27, 28, 30, 31, 33, 34, 35, 36, 38, 40,
|
||||
41, 42, 44, 45, 47, 50, 51, 52, 53, 56, 57, 59, 60, 62, 63, 64, 67, 68, 70, 71, 72, 73, 74, 76, 78, 79, 80,
|
||||
83, 85, 87, 89, 91, 93, 94, 96, 97, 99, 100, 105, 108, 116, 125, 134, 156, 158, 160, 168, 175, 183, 303,
|
||||
304, 305, 307, 308, 310, 311, 312, 313, 315, 316, 317, 319, 321, 322, 323, 324, 325, 326, 329, 330, 334,
|
||||
338, 339, 340, 341, 342, 347, 349, 351, 356, 357, 358, 360, 361, 364, 367, 368, 370, 373, 375, 376, 378,
|
||||
379, 380, 381, 383, 384, 385, 393, 396, 416, 425, 434, 460, 503, 504, 505, 507, 508, 509, 510, 511, 512,
|
||||
513, 514, 515, 516, 517, 519, 521, 522, 523, 524, 525, 529, 530, 531, 532, 534, 536, 538, 539, 540, 541,
|
||||
542, 544, 546, 549, 551, 553, 556, 557, 558, 560, 561, 562, 563, 564, 567, 568, 573, 575, 576, 577, 578,
|
||||
579, 581, 582, 583, 584, 585, 587, 591, 593, 596, 597, 634, 704, 705, 707, 708, 711, 712, 715, 716, 717,
|
||||
719, 721, 738, 739, 742, 751, 756, 758, 760, 761, 764, 768, 773, 775, 778, 781, 783, 785, 793, 805, 816,
|
||||
825, 834, 856, 858, 860, 875, 883, 905, 915, 925, 934, 939, 942, 956, 958, 961, 964, 968, 975, 983, 1005,
|
||||
1008, 1015, 1016, 1025, 1034, 1039, 1042, 1056, 1058, 1060, 1061, 1068, 1075, 1083, 1125, 1134, 1203, 1205,
|
||||
1207, 1208, 1216, 1217, 1222, 1224, 1225, 1229, 1230, 1234, 1238, 1239, 1240, 1241, 1249, 1251, 1258, 1260,
|
||||
1261, 1273, 1275, 1279, 1281, 1282, 1284, 1293, 1296, 1305, 1315, 1316, 1339, 1342, 1356, 1358, 1360, 1375,
|
||||
1383, 1401, 1402, 1404, 1405, 1406, 1407, 1408, 1409, 1411, 1412, 1414, 1415, 1416, 1417, 1418, 1419, 1420,
|
||||
1422, 1425, 1426, 1427, 1428, 1430, 1431, 1432, 1433, 1434, 1435, 1437, 1438, 1439, 1441, 1442, 1445, 1446,
|
||||
1447, 1448, 1449, 1450, 1451, 1452, 1454, 1455, 1456, 1458, 1459, 1460, 1461, 1462, 1463, 1464, 1466, 1468,
|
||||
1469, 1470, 1471, 1472, 1475, 1478, 1481, 1482, 1483, 1484, 1485, 1486, 1487, 1488, 1489, 1490, 1491, 1492,
|
||||
1493, 1494, 1495, 1496, 1498, 1499, 1500, 1505, 1516, 1525, 1534, 1558, 1560, 1575, 1605, 1615, 1616, 1625,
|
||||
1634, 1639, 1642, 1656, 1658, 1660, 1675, 1683, 1704, 1705, 1707, 1711, 1712, 1715, 1716, 1717, 1719, 1721,
|
||||
1725, 1734, 1739, 1742, 1758, 1760, 1764, 1768, 1775, 1778, 1785, 1801, 1802, 1803, 1804, 1805, 1806, 1807,
|
||||
1811, 1816, 1817, 1819, 1820, 1821, 1822, 1824, 1825, 1827, 1828, 1829, 1830, 1832, 1834, 1837, 1841, 1843,
|
||||
1844, 1848, 1853, 1854, 1855, 1856, 1858, 1860, 1862, 1864, 1865, 1868, 1875, 1879, 1882, 1885, 1890, 1895,
|
||||
1896, 1897, 1900, 1905, 1915, 1916, 1925, 1934, 1956, 1958, 1960, 1975, 1983, 2105, 2108, 2115, 2116, 2125,
|
||||
2134, 2139, 2142, 2156, 2158, 2160, 2161, 2175, 2183, 2202, 2204, 2206, 2208, 2211, 2214, 2216, 2218, 2220,
|
||||
2223, 2225, 2226, 2229, 2231, 2232, 2234, 2238, 2240, 2247, 2248, 2252, 2253, 2254, 2255, 2256, 2257, 2258,
|
||||
2259, 2260, 2262, 2263, 2266, 2267, 2270, 2271, 2274, 2275, 2305, 2325, 2334, 2375, 2404, 2411, 2416, 2426,
|
||||
2502, 2506, 2518, 2523, 2525, 2526, 2534, 2552, 2554, 2555, 2556, 2557, 2558, 2559, 2561, 2562, 2563, 2564,
|
||||
2565, 2566, 2567, 2568, 2569, 2570, 2571, 2573, 2576, 2581, 2582, 2584, 2585, 2588, 2589, 2590, 2591, 2592,
|
||||
2593, 2598, 2605, 2609, 2611, 2612, 2613, 2614, 2616, 2617, 2621, 2623, 2625, 2627, 2631, 2634, 2636, 2638,
|
||||
2644, 2646, 2653, 2657, 2660, 2662, 2663, 2675, 2676, 2677, 2678, 2687, 2691, 2697, 2705, 2708, 2715, 2716,
|
||||
2725, 2734, 2739, 2742, 2756, 2758, 2760, 2761, 2775, 2783, 2805, 2808, 2815, 2816, 2825, 2834, 2839, 2842,
|
||||
2856, 2858, 2860, 2861, 2868, 2875, 2903, 2904, 2905, 2907, 2908, 2910, 2911, 2912, 2913, 2914, 2915, 2916,
|
||||
2917, 2919, 2921, 2922, 2924, 2925, 2926, 2929, 2930, 2931, 2932, 2934, 2936, 2938, 2939, 2940, 2941, 2942,
|
||||
2944, 2946, 2947, 2949, 2951, 2953, 2956, 2958, 2960, 2961, 2962, 2964, 2967, 2968, 2970, 2973, 2975, 2977,
|
||||
2978, 2979, 2980, 2981, 2982, 2983, 2984, 2985, 2993, 2996, 3016, 3034, 3056, 3058, 3060, 3075, 3083, 3105,
|
||||
3116, 3125, 3134, 3156, 3158, 3160, 3175, 3203, 3204, 3205, 3208, 3211, 3216, 3219, 3221, 3222, 3225, 3229,
|
||||
3234, 3251, 3256, 3258, 3260, 3261, 3264, 3268, 3273, 3275, 3285, 3293, 3305, 3316, 3325, 3334, 3358, 3360,
|
||||
3375, 3405, 3425, 3434, 3475, 3525, 3534, 3605, 3616, 3625, 3634, 3658, 3660, 3675, 3705, 3715, 3716, 3725,
|
||||
3734, 3739, 3742, 3756, 3758, 3760, 3775, 3783, 3805, 3816, 3825, 3834, 3860, 3875, 3905, 3908, 3915, 3916,
|
||||
3925, 3934, 3939, 3942, 3956, 3958, 3960, 3961, 3964, 3968, 3983, 4005, 4025, 4034, 4103, 4104, 4105, 4107,
|
||||
4108, 4110, 4112, 4113, 4115, 4116, 4119, 4121, 4122, 4123, 4124, 4125, 4126, 4129, 4130, 4132, 4134, 4138,
|
||||
4139, 4140, 4141, 4142, 4147, 4149, 4151, 4160, 4161, 4164, 4167, 4168, 4170, 4173, 4175, 4176, 4178, 4179,
|
||||
4180, 4181, 4182, 4183, 4184, 4185, 4193, 4196, 4205, 4208, 4215, 4216, 4225, 4234, 4239, 4242, 4256, 4258,
|
||||
4260, 4261, 4264, 4268, 4275, 4283, 4304, 4305, 4307, 4308, 4311, 4312, 4315, 4316, 4317, 4321, 4325, 4334,
|
||||
4338, 4339, 4342, 4351, 4356, 4358, 4360, 4361, 4364, 4368, 4375, 4378, 4381, 4383, 4385, 4393, 4404, 4408,
|
||||
4411, 4412, 4417, 4419, 4421, 4425, 4434, 4438, 4439, 4442, 4461, 4464, 4468, 4478, 4504, 4505, 4508, 4511,
|
||||
4515, 4516, 4517, 4525, 4534, 4538, 4539, 4542, 4556, 4558, 4560, 4561, 4564, 4568, 4575, 4578, 4583, 4605,
|
||||
4616, 4625, 4634, 4660, 4675, 4703, 4704, 4705, 4707, 4708, 4711, 4715, 4725, 4730, 4732, 4734, 4738, 4739,
|
||||
4740, 4741, 4742, 4751, 4756, 4758, 4761, 4773, 4775, 4778, 4779, 4781, 4782, 4783, 4784, 4785, 4793, 4802,
|
||||
4805, 4806, 4807, 4808, 4809, 4811, 4812, 4813, 4814, 4815, 4816, 4817, 4818, 4819, 4820, 4821, 4823, 4824,
|
||||
4825, 4828, 4829, 4831, 4832, 4833, 4834, 4835, 4838, 4839, 4840, 4842, 4843, 4847, 4848, 4856, 4870, 4879,
|
||||
4881, 4882, 4884, 4905, 4916, 4925, 4934, 4960, 4975, 5105, 5115, 5116, 5125, 5134, 5142, 5156, 5158, 5160,
|
||||
5175, 5183, 5305, 5315, 5356, 5360, 5375, 5383, 5407, 5410, 5415, 5416, 5417, 5419, 5422, 5423, 5424, 5426,
|
||||
5427, 5432, 5433, 5435, 5437, 5438, 5447, 5449, 5451, 5452, 5453, 5454, 5462, 5464, 5468, 5469, 5471, 5472,
|
||||
5474, 5476, 5477, 5483, 5484, 5485, 5487, 5488, 5489, 5491, 5493, 5499, 5501, 5503, 5505, 5510, 5511, 5513,
|
||||
5514, 5516, 5523, 5525, 5526, 5533, 5534, 5535, 5536, 5540, 5547, 5549, 5551, 5552, 5553, 5554, 5557, 5561,
|
||||
5564, 5569, 5573, 5578, 5580, 5589, 5598, 5599, 5600, 5605, 5607, 5608, 5609, 5614, 5615, 5616, 5624, 5625,
|
||||
5626, 5627, 5628, 5630, 5631, 5634, 5636, 5637, 5639, 5640, 5641, 5642, 5644, 5646, 5647, 5650, 5653, 5654,
|
||||
5655, 5656, 5657, 5658, 5659, 5660, 5661, 5662, 5665, 5666, 5667, 5668, 5670, 5672, 5674, 5675, 5676, 5677,
|
||||
5679, 5680, 5683, 5684, 5685, 5686, 5687, 5689, 5690, 5691, 5695, 5696, 5697, 5699, 5700, 5705, 5725, 5734,
|
||||
5760, 5775, 5804, 5805, 5808, 5815, 5816, 5817, 5819, 5821, 5825, 5834, 5838, 5839, 5842, 5856, 5858, 5860,
|
||||
5861, 5864, 5868, 5875, 5878, 5883, 5885, 5906, 5920, 5929, 5931, 5932, 5934, 5952, 5954, 5955, 5956, 5957,
|
||||
5958, 5961, 5962, 5964, 5966, 5968, 5969, 5970, 5971, 5973, 5975, 5976, 5979, 5981, 5982, 5984, 5988, 5989,
|
||||
5990, 5992, 5997, 5998, 6001, 6002, 6003, 6004, 6005, 6007, 6008, 6009, 6010, 6013, 6014, 6015, 6017, 6018,
|
||||
6019, 6020, 6021, 6022, 6023, 6024, 6025, 6026, 6027, 6028, 6029, 6031, 6032, 6033, 6034, 6035, 6036, 6037,
|
||||
6038, 6039, 6040, 6041, 6042, 6043, 6044, 6045, 6046, 6047, 6048, 6049, 6050, 6051, 6052, 6054, 6055, 6056,
|
||||
6057, 6059, 6060, 6061, 6062, 6063, 6066, 6067, 6068, 6070, 6071, 6072, 6073, 6074, 6077, 6078, 6079, 6080,
|
||||
6081, 6083, 6085, 6086, 6087, 6089, 6091, 6092, 6093, 6094, 6095, 6096, 6097, 6099, 6100, 6105, 6116, 6125,
|
||||
6134, 6160, 6175, 6205, 6216, 6225, 6234, 6258, 6305, 6315, 6316, 6325, 6334, 6342, 6356, 6358, 6360, 6375,
|
||||
6383, 6405, 6411, 6412, 6415, 6416, 6417, 6421, 6425, 6434, 6438, 6439, 6442, 6460, 6461, 6464, 6468, 6478,
|
||||
6483, 6502, 6504, 6506, 6508, 6511, 6514, 6516, 6518, 6520, 6523, 6525, 6526, 6529, 6531, 6532, 6534, 6540,
|
||||
6547, 6548, 6554, 6555, 6558, 6560, 6563, 6566, 6567, 6571, 6575, 6579, 6583, 6591, 6592, 6600, 6605, 6625,
|
||||
6634, 6660, 6675, 6705, 6708, 6715, 6716, 6725, 6734, 6739, 6742, 6756, 6758, 6760, 6761, 6768, 6775, 6783,
|
||||
6805, 6808, 6811, 6812, 6815, 6816, 6817, 6821, 6825, 6834, 6839, 6842, 6856, 6858, 6860, 6861, 6864, 6875,
|
||||
6883, 6904, 6908, 6910, 6913, 6916, 6917, 6924, 6928, 6930, 6933, 6936, 6937, 6940, 6944, 6948, 6950, 6956,
|
||||
6964, 6968, 6970, 6973, 6976, 6982, 6983, 6984, 6985, 6986, 6987, 6988, 6989, 6990, 6992, 6993, 6994, 6995,
|
||||
6996, 6997, 6998, 6999, 7000, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7011, 7012, 7013, 7014, 7015,
|
||||
7016, 7017, 7018, 7019, 7020, 7021, 7022, 7023, 7024, 7025, 7027, 7028, 7029, 7030, 7031, 7032, 7033, 7034,
|
||||
7035, 7036, 7037, 7038, 7040, 7041, 7042, 7044, 7045, 7046, 7047, 7048, 7049, 7050, 7051, 7052, 7053, 7055,
|
||||
7056, 7057, 7058, 7061, 7062, 7064, 7065, 7066, 7068, 7069, 7070, 7071, 7073, 7075, 7076, 7077, 7078, 7079,
|
||||
7081, 7082, 7083, 7084, 7085, 7086, 7087, 7090, 7091, 7093, 7094, 7095, 7096, 7097, 7099, 7100, 7102, 7105,
|
||||
7106, 7111, 7112, 7114, 7116, 7118, 7119, 7120, 7123, 7125, 7129, 7131, 7132, 7134, 7139, 7140, 7142, 7148,
|
||||
7152, 7153, 7154, 7159, 7160, 7162, 7163, 7164, 7169, 7170, 7172, 7173, 7180, 7204, 7206, 7207, 7208, 7211,
|
||||
7214, 7216, 7218, 7223, 7226, 7229, 7232, 7234, 7238, 7240, 7248, 7256, 7261, 7266, 7270, 7279, 7281, 7282,
|
||||
7284, 7306, 7316, 7320, 7331, 7332, 7334, 7352, 7354, 7356, 7357, 7358, 7361, 7364, 7366, 7368, 7369, 7370,
|
||||
7373, 7376, 7379, 7381, 7382, 7384, 7388, 7390, 7392, 7397, 7398, 7402, 7404, 7406, 7408, 7411, 7414, 7416,
|
||||
7420, 7423, 7425, 7426, 7429, 7431, 7432, 7434, 7440, 7447, 7448, 7451, 7452, 7453, 7456, 7457, 7458, 7459,
|
||||
7461, 7462, 7465, 7466, 7468, 7469, 7470, 7471, 7472, 7473, 7474, 7475, 7476, 7477, 7478, 7479, 7481, 7482,
|
||||
7483, 7484, 7485, 7486, 7487, 7488, 7492, 7493, 7494, 7497, 7499, 7506, 7520, 7532, 7534, 7552, 7553, 7554,
|
||||
7555, 7557, 7558, 7559, 7560, 7562, 7563, 7564, 7569, 7570, 7573, 7574, 7577, 7578, 7580, 7583, 7586, 7587,
|
||||
7590, 7597, 7598, 7599, 7603, 7604, 7605, 7606, 7607, 7608, 7609, 7610, 7612, 7616, 7617, 7620, 7621, 7624,
|
||||
7625, 7626, 7627, 7656, 7661, 7666, 7670, 7679, 7681, 7682, 7684, 7704, 7705, 7707, 7708, 7711, 7712, 7715,
|
||||
7716, 7717, 7724, 7725, 7730, 7732, 7734, 7738, 7739, 7740, 7741, 7742, 7747, 7751, 7756, 7758, 7760, 7761,
|
||||
7773, 7775, 7778, 7779, 7781, 7782, 7783, 7784, 7785, 7793, 7796, 8005, 8008, 8011, 8017, 8021, 8022, 8025,
|
||||
8026, 8032, 8034, 8038, 8039, 8042, 8047, 8049, 8051, 8058, 8060, 8061, 8064, 8068, 8070, 8075, 8078, 8080,
|
||||
8081, 8082, 8083, 8084, 8093, 8096, 8103, 8104, 8105, 8106, 8107, 8108, 8109, 8110, 8112, 8113, 8116, 8117,
|
||||
8119, 8120, 8124, 8128, 8130, 8136, 8137, 8142, 8143, 8144, 8145, 8146, 8147, 8148, 8149, 8150, 8152, 8153,
|
||||
8156, 8157, 8158, 8159, 8160, 8162, 8163, 8164, 8165, 8166, 8167, 8168, 8169, 8170, 8172, 8173, 8176, 8177,
|
||||
8178, 8179, 8180, 8184, 8188, 8190, 8196, 8197, 8201, 8202, 8203, 8204, 8205, 8206, 8207, 8208, 8209, 8210,
|
||||
8211, 8212, 8213, 8214, 8215, 8216, 8217, 8218, 8219, 8220, 8221, 8222, 8223, 8224, 8225, 8226, 8227, 8228,
|
||||
8229, 8230, 8231, 8232, 8233, 8234, 8235, 8236, 8237, 8238, 8239, 8240, 8241, 8242, 8243, 8244, 8245, 8246,
|
||||
8247, 8248, 8249, 8250, 8251, 8252, 8253, 8254, 8255, 8256, 8257, 8258, 8260, 8263, 8264, 8265, 8266, 8267,
|
||||
8270, 8274, 8275, 8278, 8279, 8280, 8282, 8283, 8286, 8287, 8290, 8292, 8294, 8296, 8298, 8301, 8304, 8306,
|
||||
8310, 8313, 8315, 8316, 8319, 8321, 8322, 8324, 8330, 8337, 8338, 8364, 8365, 8368, 8370, 8372, 8373, 8376,
|
||||
8377, 8380, 8384, 8385, 8388, 8390, 8393, 8396, 8397, 8400, 8410, 8412, 8416, 8420, 8422, 8425, 8427, 8429,
|
||||
8437, 8439, 8440, 8445, 8449, 8452, 8454, 8455, 8458, 8459, 8462, 8477, 8484, 8485, 8486, 8488, 8490, 8492,
|
||||
8495, 8503, 8513, 8526, 8528, 8529, 8536, 8537, 8540, 8543, 8546, 8547, 8550, 8552, 8557, 8559, 8560, 8565,
|
||||
8568, 8569, 8574, 8580, 8593, 8599, 8606, 8619, 8622, 8627, 8629, 8630, 8636, 8646, 8648, 8655, 8656, 8664,
|
||||
8666, 8668, 8669, 8671, 8672, 8677, 8679, 8680, 8699, 8704, 8705, 8707, 8711, 8717, 8718, 8719, 8721, 8722,
|
||||
8735, 8739, 8741, 8743, 8748, 8751, 8757, 8762, 8765, 8769, 8775, 8776, 8782, 8783, 8790, 8794, 8904, 8906,
|
||||
8908, 8911, 8914, 8916, 8918, 8920, 8925, 8926, 8929, 8931, 8932, 8934, 8938, 8939, 8940, 8942, 8947, 8948,
|
||||
8952, 8954, 8956, 8957, 8958, 8959, 8960, 8962, 8963, 8966, 8967, 8968, 8969, 8973, 8974, 8976, 8977, 8978,
|
||||
8979, 8980, 8982, 8983, 8986, 8987, 8990, 8991, 8994, 8997, 8998, 8999, 9002, 9004, 9006, 9008, 9011, 9014,
|
||||
9016, 9018, 9020, 9023, 9026, 9029, 9031, 9032, 9034, 9038, 9040, 9048, 9052, 9054, 9056, 9057, 9058, 9061,
|
||||
9064, 9066, 9068, 9069, 9070, 9073, 9075, 9076, 9079, 9081, 9082, 9084, 9088, 9089, 9090, 9092, 9097, 9098,
|
||||
9102, 9103, 9104, 9109, 9110, 9112, 9113, 9114, 9117, 9119, 9120, 9122, 9123, 9125, 9126, 9127, 9128, 9129,
|
||||
9132, 9133, 9134, 9136, 9137, 9138, 9140, 9141, 9144, 9146, 9147, 9148, 9149, 9151, 9153, 9159, 9160, 9167,
|
||||
9168, 9171, 9172, 9173, 9174, 9176, 9177, 9178, 9180, 9183, 9184, 9185, 9186, 9187, 9189, 9192, 9194, 9195,
|
||||
9199, 9201, 9202, 9203, 9204, 9205, 9206, 9207, 9208, 9210, 9212, 9214, 9215, 9216, 9217, 9218, 9219, 9221,
|
||||
9222, 9223, 9224, 9225, 9226, 9227, 9228, 9229, 9231, 9233, 9234, 9235, 9236, 9238, 9239, 9242, 9243, 9244,
|
||||
9245, 9248, 9249, 9250, 9251, 9252, 9253, 9257, 9258, 9262, 9264, 9265, 9266, 9267, 9268, 9271, 9273, 9275,
|
||||
9276, 9279, 9280, 9283, 9286, 9292, 9293, 9294, 9295, 9298, 9323, 9324, 9327, 9328, 9330, 9333, 9340, 9348,
|
||||
9349, 9353, 9354, 9355, 9357, 9360, 9361, 9362, 9363, 9364, 9365, 9366, 9367, 9369, 9371, 9372, 9373, 9374,
|
||||
9375, 9379, 9380, 9384, 9386, 9388, 9389, 9390, 9391, 9392, 9394, 9399, 9401, 9403, 9406, 9407, 9408, 9410,
|
||||
9412, 9414, 9417, 9418, 9423, 9425, 9426, 9427, 9428, 9429, 9431, 9432, 9433, 9435, 9443, 9446, 9447, 9472,
|
||||
9473, 9488, 9490, 9496, 9497, 9502, 9504, 9506, 9507, 9508, 9514, 9516, 9518, 9519, 9520, 9521, 9523, 9525,
|
||||
9529, 9531, 9532, 9534, 9538, 9539, 9540, 9542, 9547, 9548, 9554, 9558, 9560, 9566, 9567, 9572, 9574, 9575,
|
||||
9576, 9577, 9578, 9579, 9580, 9581, 9582, 9583, 9584, 9585, 9586, 9587, 9588, 9589, 9590, 9591, 9593, 9594,
|
||||
9595, 9596, 9598, 9599, 9601, 9602, 9603, 9604, 9605, 9608, 9609, 9610, 9611, 9612, 9613, 9617, 9618, 9620,
|
||||
9636, 9638, 9641, 9653, 9655, 9664, 9667, 9689, 9696, 9703, 9704, 9705, 9707, 9714, 9726, 9736, 9741, 9743,
|
||||
9761, 9772, 9774, 9776, 9777, 9778, 9781, 9784, 9786, 9788, 9789, 9790, 9791, 9793, 9795, 9796, 9799, 9801,
|
||||
9802, 9804, 9808, 9809, 9810, 9812, 9817, 9818, 9822, 9823, 9824, 9827, 9829, 9830, 9832, 9844, 9845, 9848,
|
||||
9849, 9850, 9852, 9853, 9856, 9857, 9860, 9862, 9864, 9865, 9866, 9867, 9868, 9871, 9873, 9875, 9876, 9879,
|
||||
9880, 9883, 9886, 9892, 9893, 9894, 9895, 9898, 9904, 9906, 9911, 9914, 9916, 9920, 9926, 9929, 9931, 9932,
|
||||
9934, 9940, 9948, 9953, 9954, 9958, 9963, 9979, 9981, 9982, 9983, 9984, 9985, 9986, 9987, 9988, 9989, 9990,
|
||||
9991, 9992, 9993, 9994, 9996, 9997, 9998, 9999, 10000, 10003, 10004, 10005, 10006, 10007, 10008, 10009,
|
||||
10010, 10012, 10013, 10016, 10017, 10018, 10019, 10020, 10022, 10023, 10024, 10029, 10030, 10033, 10037,
|
||||
10038, 10043, 10050, 10059, 10062, 10064, 10065, 10066, 10067, 10068, 10069, 10071, 10073, 10075, 10076,
|
||||
10077, 10078, 10079, 10080, 10081, 10083, 10084, 10085, 10086, 10088, 10089, 10091, 10092, 10093, 10094,
|
||||
10095, 10098, 10100, 10104, 10108, 10110, 10113, 10116, 10117, 10122, 10142, 10143, 10144, 10145, 10146,
|
||||
10147, 10148, 10149, 10152, 10153, 10154, 10157, 10158, 10159, 10160, 10168, 10177, 10184, 10185, 10188,
|
||||
10190, 10192, 10193, 10196, 10197, 10200, 10270, 10276, 10277, 10284, 10288, 10290, 10296, 10297, 30005,
|
||||
30015, 30016, 30025, 30034, 30056, 30058, 30060, 30083, 30104, 30105, 30107, 30108, 30111, 30112, 30115,
|
||||
30116, 30117, 30119, 30121, 30125, 30134, 30138, 30139, 30142, 30156, 30158, 30160, 30161, 30164, 30168,
|
||||
30175, 30178, 30181, 30183, 30185,
|
||||
];
|
||||
|
||||
public static readonly string[] Names =
|
||||
[
|
||||
"GetLevelRewardTakenListScRsp", "PlayerLogoutCsReq", "ExchangeStaminaCsReq", "GetVideoVersionKeyCsReq",
|
||||
"DailyRefreshNotify", "AntiAddictScNotify", "RegionStopScNotify", "SetGameplayBirthdayCsReq",
|
||||
"GateServerScNotify", "EnterStageCsReq", "PlayerGetTokenScRsp", "SetNicknameCsReq",
|
||||
"UpdatePlayerSettingScRsp", "GetLevelRewardCsReq", "GetAuthkeyScRsp", "SetGameplayBirthdayScRsp",
|
||||
"PlayerLoginScRsp", "UpdateFeatureSwitchScNotify", "GetVideoVersionKeyScRsp",
|
||||
"ReserveStaminaExchangeCsReq", "SetPlayerInfoScRsp", "GetSecretKeyInfoCsReq", "KEKFNKDLDOM",
|
||||
"PlayerLoginCsReq", "ClientObjUploadScRsp", "FeatureSwitchClosedScNotify", "SetNicknameScRsp",
|
||||
"SetGenderCsReq", "SetPlayerInfoCsReq", "EnterStageScRsp", "PlayerHeartBeatCsReq",
|
||||
"UpdatePlayerSettingCsReq", "QueryProductInfoScRsp", "ReserveStaminaExchangeScRsp", "SetLanguageCsReq",
|
||||
"ClientObjDownloadDataScNotify", "RetcodeNotify", "GmTalkScNotify", "AceAntiCheaterScRsp",
|
||||
"PlayerForceSyncGameStateFinishCsReq", "PlayerGetTokenCsReq", "PlayerHeartBeatScRsp",
|
||||
"PlayerLoginFinishScRsp", "GetAuthkeyCsReq", "GetBasicInfoScRsp", "ExchangeStaminaScRsp",
|
||||
"ClientDownloadDataScNotify", "IDAIHHGGPIJ", "EMFKFGCBJBP", "ServerAnnounceNotify",
|
||||
"PlayerForceSyncGameStateFinishScRsp", "AceAntiCheaterCsReq", "GetLevelRewardTakenListCsReq",
|
||||
"SetGenderScRsp", "GetBasicInfoCsReq", "PlayerSqueezedScNotify", "GetLevelRewardScRsp",
|
||||
"PlayerLoginFinishCsReq", "AFDOMLOABJB", "GetSecretKeyInfoScRsp", "SetLanguageScRsp",
|
||||
"ClientObjUploadCsReq", "QueryProductInfoCsReq", "MonthCardRewardNotify", "StaminaInfoScNotify",
|
||||
"SetRedPointStatusScNotify", "QuitBattleCsReq", "RebattleByClientCsNotify", "GetCurBattleInfoScRsp",
|
||||
"PVEBattleResultScRsp", "PVEBattleResultCsReq", "SyncClientResVersionScRsp", "SyncClientResVersionCsReq",
|
||||
"GetCurBattleInfoCsReq", "BattleReplayScNotify", "QuitBattleScRsp", "QuitBattleScNotify",
|
||||
"SetGrowthTargetAvatarScRsp", "DressAvatarSkinScRsp", "AvatarExpUpCsReq", "UnlockAvatarSkinScNotify",
|
||||
"RankUpAvatarCsReq", "SetMultipleAvatarPathsScRsp", "TakeOffRelicScRsp", "TakeOffRelicCsReq",
|
||||
"AvatarSpecialSkilltreeUnlockScNotify", "DressAvatarScRsp", "UnlockSkillTreeScRsp",
|
||||
"TakePromotionRewardCsReq", "TakeOffAvatarSkinCsReq", "DressRelicAvatarScRsp",
|
||||
"GetPreAvatarGrowthInfoCsReq", "LENDBHPIOJK", "SetPlayerOutfitScRsp", "GetAvatarDataScRsp",
|
||||
"UnlockAvatarPathCsReq", "GrowthTargetAvatarChangedScNotify", "SetPlayerOutfitCsReq", "GetAvatarDataCsReq",
|
||||
"TakePromotionRewardScRsp", "TakeOffEquipmentScRsp", "SetAvatarEnhancedIdScRsp",
|
||||
"GetPreAvatarActivityListScRsp", "TakeOffEquipmentCsReq", "SetAvatarPathScRsp",
|
||||
"GetPreAvatarGrowthInfoScRsp", "MarkAvatarScRsp", "PromoteAvatarScRsp", "PMEMIDKDAFG",
|
||||
"PromoteAvatarCsReq", "UnlockSkillTreeCsReq", "AddAvatarScNotify", "DressRelicAvatarCsReq",
|
||||
"SetMultipleAvatarPathsCsReq", "RankUpAvatarScRsp", "AvatarPathChangedNotify",
|
||||
"SetGrowthTargetAvatarCsReq", "AvatarExpUpScRsp", "EKLIDIDKFHC", "DressAvatarSkinCsReq",
|
||||
"GetPreAvatarActivityListCsReq", "UnlockAvatarPathScRsp", "MarkAvatarCsReq", "DressAvatarCsReq",
|
||||
"SetAvatarEnhancedIdCsReq", "TakeOffAvatarSkinScRsp", "AddMultiPathAvatarScNotify", "SetAvatarPathCsReq",
|
||||
"GetChapterScRsp", "GetWaypointScRsp", "LKJFAOIDNNF", "JBAEGHHHDNH", "DestroyItemCsReq",
|
||||
"ExchangeHcoinScRsp", "PromoteEquipmentCsReq", "AddEquipmentScRsp", "ComposeItemScRsp",
|
||||
"PromoteEquipmentScRsp", "GetRelicFilterPlanScRsp", "SellItemCsReq", "LockRelicScRsp",
|
||||
"AddRelicFilterPlanCsReq", "RelicFilterPlanClearNameScNotify", "RankUpEquipmentScRsp",
|
||||
"LockEquipmentScRsp", "SellItemScRsp", "ComposeSelectedRelicCsReq", "LockRelicCsReq",
|
||||
"GetMarkItemListCsReq", "AddRelicFilterPlanScRsp", "DiscardRelicCsReq", "GetBagScRsp", "DestroyItemScRsp",
|
||||
"GeneralVirtualItemDataNotify", "RelicReforgeConfirmScRsp", "MarkItemScRsp", "GetBagCsReq",
|
||||
"RelicReforgeCsReq", "RechargeSuccNotify", "ExpUpEquipmentScRsp", "SyncTurnFoodNotify",
|
||||
"SetTurnFoodSwitchScRsp", "ExpUpEquipmentCsReq", "MarkRelicFilterPlanCsReq", "RelicReforgeConfirmCsReq",
|
||||
"GetMarkItemListScRsp", "GetRecyleTimeScRsp", "DeleteRelicFilterPlanScRsp", "UseItemScRsp",
|
||||
"ModifyRelicFilterPlanScRsp", "UseItemCsReq", "LockEquipmentCsReq", "ComposeItemCsReq",
|
||||
"MarkRelicFilterPlanScRsp", "EOBJKAMNNPI", "ExpUpRelicScRsp", "GetRelicFilterPlanCsReq", "ExpUpRelicCsReq",
|
||||
"ComposeLimitNumUpdateScNotify", "EEFFCJHHLNG", "ModifyRelicFilterPlanCsReq", "RelicReforgeScRsp",
|
||||
"ExchangeHcoinCsReq", "SetTurnFoodSwitchCsReq", "GetRecyleTimeCsReq", "MarkItemCsReq",
|
||||
"RankUpEquipmentCsReq", "CancelMarkItemNotify", "ComposeSelectedRelicScRsp", "BatchRankUpEquipmentScRsp",
|
||||
"BatchRankUpEquipmentCsReq", "ComposeLimitNumCompleteScNotify", "DiscardRelicScRsp",
|
||||
"DeleteRelicFilterPlanCsReq", "PlayerSyncScNotify", "ReplaceLineupCsReq", "GetCurLineupDataCsReq",
|
||||
"VirtualLineupTrialAvatarChangeScNotify", "ChangeLineupLeaderCsReq", "SetLineupNameScRsp",
|
||||
"SetLineupNameCsReq", "SwapLineupScRsp", "JoinLineupScRsp", "GetAllLineupDataCsReq", "ReplaceLineupScRsp",
|
||||
"SwitchLineupIndexScRsp", "GetAllLineupDataScRsp", "GetLineupAvatarDataCsReq", "SyncLineupNotify",
|
||||
"CNCFCDJAFOJ", "QuitLineupScRsp", "QuitLineupCsReq", "JoinLineupCsReq", "GetLineupAvatarDataScRsp",
|
||||
"SwitchLineupIndexCsReq", "ChangeLineupLeaderScRsp", "SetTeamFavourite", "GetCurLineupDataScRsp",
|
||||
"HPHANFONFPF", "SyncAllPresetLineupNotify", "SwapLineupCsReq", "ExtraLineupDestroyNotify",
|
||||
"MarkPresetLineupCsReq", "MarkReadMailCsReq", "DelMailScRsp", "GetMailScRsp", "GetMailCsReq",
|
||||
"TakeMailAttachmentScRsp", "TakeMailAttachmentCsReq", "DelMailCsReq", "MarkReadMailScRsp",
|
||||
"NewMailScNotify", "EMHALMBFHKG", "FinishQuestCsReq", "GetQuestDataScRsp", "GetQuestDataCsReq",
|
||||
"TakeQuestOptionalRewardCsReq", "BICDPPOGHND", "GetQuestRecordScRsp", "GetQuestRecordCsReq",
|
||||
"TakeQuestOptionalRewardScRsp", "BatchGetQuestDataScRsp", "JGAONCGEIND", "TakeQuestRewardScRsp",
|
||||
"QuestRecordScNotify", "MultiplayerGetFightGateCsReq", "GetFriendRankingInfoRsp", "GetMatchPlayDataCsReq",
|
||||
"JJAGFPLDCAF", "FightGameStateScRsp", "FightGameStateCsReq", "MatchPlayDataChangeNtf",
|
||||
"MultiplayerGetMatchPlayDataScRsp", "HFFCEACMEIG", "FightGameStartScNotify", "IHGLPGLPDFF",
|
||||
"GetFriendRankingInfoCsReq", "SelfRankChangeNtf", "HPGIGMPBIBO", "MultiplayerMatch3FinishScNotify",
|
||||
"FinishPlotScRsp", "FinishPlotCsReq", "AcceptMainMissionCsReq", "FinishTalkMissionCsReq",
|
||||
"SubMissionRewardScNotify", "FinishCosumeItemMissionScRsp", "LIFMGAFOBPC", "GetMissionStatusCsReq",
|
||||
"GetMainMissionCustomValueCsReq", "StarPerformanceRelayCsReq", "GetMissionDataScRsp",
|
||||
"AcceptMainMissionScRsp", "UpdateMainMissionCustomValueScRsp", "GetMissionDataCsReq",
|
||||
"GetMissionStatusScRsp", "MissionGroupWarnScNotify", "UpdateTrackMainMissionScRsp",
|
||||
"UpdateMainMissionCustomValueCsReq", "GetMainMissionCustomValueScRsp", "TeleportToMissionResetPointScRsp",
|
||||
"MissionSyncTaskScRsp", "MissionRewardScNotify", "FinishCosumeItemMissionCsReq",
|
||||
"StartFinishMainMissionScNotify", "FinishTalkMissionScRsp", "FinishedMissionScNotify",
|
||||
"TeleportToMissionResetPointCsReq", "CPFCAJBHOIB", "UpdateTrackMainMissionCsReq",
|
||||
"StartFinishSubMissionScNotify", "StarPerformanceRelayScRsp", "GetFarmStageGachaInfoCsReq",
|
||||
"CocoonSweepScRsp", "QuickStartCocoonStageScRsp", "FarmElementSweepScRsp", "FarmElementSweepCsReq",
|
||||
"QuickStartFarmElementScRsp", "QuickStartFarmElementCsReq", "QuickStartCocoonStageCsReq",
|
||||
"GetFarmStageGachaInfoScRsp", "CocoonSweepCsReq", "SetTrainWorldIdScRsp", "RefreshTriggerByClientCsReq",
|
||||
"SceneEnterStageCsReq", "InteractPropCsReq", "RefreshTriggerByClientScRsp",
|
||||
"SceneReviveAfterRebattleScRsp", "SceneCastSkillCostMpCsReq", "EnterSceneScRsp",
|
||||
"SceneCheckpoingRefreshScRsp", "SceneCheckpoingRefreshCsReq", "SetGroupCustomSaveDataScRsp",
|
||||
"ChangePropTimelineInfoScRsp", "SceneCastSkillScRsp", "LastSpringRefreshTimeNotify",
|
||||
"UpdateFloorSavedValueNotify", "SceneEnterStageScRsp", "EnteredSceneChangeScNotify",
|
||||
"RecoverAllLineupCsReq", "SceneEntityMoveScRsp", "ActiveFarmElementScRsp", "EnterSceneByServerScNotify",
|
||||
"SyncServerSceneChangeNotify", "SetClientPausedScRsp", "ReEnterLastElementStageScRsp",
|
||||
"StartCocoonStageCsReq", "SceneGroupRefreshScNotify", "SceneEntityMoveCsReq",
|
||||
"GameplayCounterUpdateScNotify", "GetSceneMapInfoScRsp", "OOFDADLENDH", "SceneUpdatePositionVersionNotify",
|
||||
"SetClientPausedCsReq", "SceneEntityMoveScNotify", "OpenChestScNotify", "ReEnterLastElementStageCsReq",
|
||||
"DeactivateFarmElementScRsp", "GetEnteredSceneScRsp", "RecoverAllLineupScRsp", "GetUnlockTeleportCsReq",
|
||||
"EnterSectionScRsp", "CounterDownCsReq", "DeleteSummonUnitCsReq", "GetEnteredSceneCsReq",
|
||||
"GetCurSceneInfoScRsp", "GetCurSceneInfoCsReq", "TrainWorldIdChangeScNotify", "SceneCastSkillCsReq",
|
||||
"SyncEntityBuffChangeListScNotify", "SetGroupCustomSaveDataCsReq", "EnterSceneCsReq",
|
||||
"SceneCastSkillMpUpdateScNotify", "GroupStateChangeCsReq", "SceneCastSkillCostMpScRsp", "GMABBKEMPNP",
|
||||
"ActiveFarmElementCsReq", "EBADAHNBIIH", "DeleteSummonUnitScRsp", "InteractPropScRsp",
|
||||
"ReturnLastTownScRsp", "EnterSectionCsReq", "SavePointsInfoNotify", "ChangePropTimelineInfoCsReq",
|
||||
"StartCocoonStageScRsp", "SceneReviveAfterRebattleCsReq", "GroupStateChangeScRsp",
|
||||
"SceneEntityTeleportScRsp", "UnlockTeleportNotify", "SetTrainWorldIdCsReq",
|
||||
"RefreshTriggerByClientScNotify", "SceneEntityTeleportCsReq", "UpdateGroupPropertyScRsp", "OCHPFJEKNGM",
|
||||
"CounterDownScRsp", "GetSceneMapInfoCsReq", "DeactivateFarmElementCsReq", "UpdateGroupPropertyCsReq",
|
||||
"GetUnlockTeleportScRsp", "ScenePlaneEventScNotify", "BuyGoodsCsReq", "TakeCityShopRewardScRsp",
|
||||
"GetShopListScRsp", "GetShopListCsReq", "CityShopInfoScNotify", "TakeCityShopRewardCsReq", "BuyGoodsScRsp",
|
||||
"GetTutorialGuideCsReq", "FinishTutorialScRsp", "UnlockTutorialScRsp", "GetTutorialScRsp",
|
||||
"GetTutorialCsReq", "FinishTutorialGuideScRsp", "FinishTutorialGuideCsReq", "UnlockTutorialGuideScRsp",
|
||||
"UnlockTutorialGuideCsReq", "UnlockTutorialCsReq", "GetTutorialGuideScRsp", "FinishTutorialCsReq",
|
||||
"RestartChallengePhaseScRsp", "StartChallengeCsReq", "ChallengeBossPhaseSettleNotify",
|
||||
"StartPartialChallengeCsReq", "GetChallengeGroupStatisticsScRsp", "GetCurChallengeCsReq",
|
||||
"LeaveChallengeScRsp", "StartPartialChallengeScRsp", "EnterChallengeNextPhaseCsReq",
|
||||
"GetChallengeGroupStatisticsCsReq", "GetChallengeScRsp", "GetChallengeCsReq", "ChallengeLineupNotify",
|
||||
"GetCurChallengeScRsp", "ChallengeSettleNotify", "LeaveChallengeCsReq", "TakeChallengeRewardScRsp",
|
||||
"TakeChallengeRewardCsReq", "StartChallengeScRsp", "RestartChallengePhaseCsReq",
|
||||
"EnterChallengeNextPhaseScRsp", "SyncRogueVirtualItemInfoScNotify", "GetRogueTalentInfoCsReq",
|
||||
"SyncRogueReviveInfoScNotify", "GetRogueBuffEnhanceInfoCsReq", "StartRogueCsReq",
|
||||
"GetRogueTalentInfoScRsp", "EnhanceRogueBuffScRsp", "ReviveRogueAvatarCsReq", "EnterRogueScRsp",
|
||||
"ReviveRogueAvatarScRsp", "GetRogueBuffEnhanceInfoScRsp", "FinishAeonDialogueGroupScRsp",
|
||||
"PickRogueAvatarScRsp", "QuitRogueScRsp", "GetRogueInitialScoreScRsp", "GetRogueInfoScRsp",
|
||||
"SyncRogueAeonScNotify", "TakeRogueAeonLevelRewardScRsp", "QuitRogueCsReq", "KLFPHOOPELB",
|
||||
"SyncRogueSeasonFinishScNotify", "GetRogueInfoCsReq", "TakeRogueAeonLevelRewardCsReq",
|
||||
"EnterRogueMapRoomScRsp", "SyncRogueStatusScNotify", "ExchangeRogueRewardKeyCsReq",
|
||||
"FinishAeonDialogueGroupCsReq", "OpenRogueChestScRsp", "EnableRogueTalentScRsp", "GetRogueAeonInfoScRsp",
|
||||
"LeaveRogueScRsp", "LeaveRogueCsReq", "EnterRogueCsReq", "ExchangeRogueRewardKeyScRsp",
|
||||
"PickRogueAvatarCsReq", "GetRogueAeonInfoCsReq", "SyncRogueFinishScNotify", "StartRogueScRsp",
|
||||
"EnterRogueMapRoomCsReq", "SyncRogueExploreWinScNotify", "EnhanceRogueBuffCsReq", "EnableRogueTalentCsReq",
|
||||
"SyncRogueGetItemScNotify", "SyncRogueMapRoomScNotify", "OpenRogueChestCsReq",
|
||||
"SyncRogueAreaUnlockScNotify", "DoGachaCsReq", "SetGachaDecideItemScRsp", "GetGachaCeilingScRsp",
|
||||
"GetGachaInfoScRsp", "GetGachaInfoCsReq", "ExchangeGachaCeilingScRsp", "ExchangeGachaCeilingCsReq",
|
||||
"GetGachaCeilingCsReq", "DoGachaScRsp", "SetGachaDecideItemCsReq", "TakeTalkRewardCsReq",
|
||||
"FinishFirstTalkByPerformanceNpcScRsp", "SelectInclinationTextScRsp", "GetFirstTalkNpcScRsp",
|
||||
"GetNpcTakenRewardScRsp", "GetNpcTakenRewardCsReq", "GetFirstTalkByPerformanceNpcScRsp",
|
||||
"GetFirstTalkByPerformanceNpcCsReq", "FinishFirstTalkNpcScRsp", "FinishFirstTalkNpcCsReq",
|
||||
"GetFirstTalkNpcCsReq", "FinishFirstTalkByPerformanceNpcCsReq", "TakeTalkRewardScRsp",
|
||||
"SelectInclinationTextCsReq", "GetAllSaveRaidScRsp", "ChallengeRaidNotify", "StartRaidScRsp",
|
||||
"SetClientRaidTargetCountScRsp", "TakeChallengeRaidRewardCsReq", "GetRaidInfoScRsp",
|
||||
"GetChallengeRaidInfoScRsp", "DelSaveRaidScNotify", "LeaveRaidCsReq", "GetAllSaveRaidCsReq",
|
||||
"GetSaveRaidScRsp", "TakeChallengeRaidRewardScRsp", "RaidInfoNotify", "LeaveRaidScRsp", "OICLHALJIEH",
|
||||
"StartRaidCsReq", "RaidKickByServer", "SetClientRaidTargetCountCsReq", "GetSaveRaidCsReq",
|
||||
"GetRaidInfoCsReq", "GetFiveDimMoneyDataScRsp", "FiveDimMoneyChangeScNotify", "LeaveFiveDimGameCsReq",
|
||||
"UpdateFiveDimGameDataScRsp", "GetFiveDimMoneyScRsp", "GetFiveDimMoneyCsReq", "EnterFiveDimGameScRsp",
|
||||
"FiveDimJumpEnergyChangeScNotify", "FiveDimGameTransferCsReq", "FiveDimGameTransferScRsp",
|
||||
"UpdateFiveDimGameDataCsReq", "EnterFiveDimGameCsReq", "GetFiveDimGameDataScRsp",
|
||||
"FiveDimGameDataUpdateScNotify", "LeaveFiveDimGameScRsp", "GetFiveDimMoneyDataCsReq",
|
||||
"GetFiveDimGameDataCsReq", "GetUpdatedArchiveDataCsReq", "GetArchiveDataScRsp", "GetArchiveDataCsReq",
|
||||
"GetUpdatedArchiveDataScRsp", "GetBigDataAllRecommendScRsp", "GetBigDataRecommendScRsp",
|
||||
"GetBigDataRecommendCsReq", "GetBigDataAllRecommendCsReq", "TakeMultipleExpeditionRewardCsReq",
|
||||
"GetExpeditionDataScRsp", "TakeMultipleExpeditionRewardScRsp", "AcceptMultipleExpeditionScRsp",
|
||||
"AcceptMultipleExpeditionCsReq", "HKCILGGEEFM", "GetExpeditionDataCsReq",
|
||||
"ElfRestaurantUpgradeEmployeeLevelScRsp", "ElfRestaurantPlantSeedScRsp",
|
||||
"ElfRestaurantFinishTradeOrderCsReq", "GetElfRestaurantDataScRsp", "ElfRestaurantUpgradeRecipeLevelCsReq",
|
||||
"ElfRestaurantRecycleSeedScRsp", "ElfRestaurantTakeVillagerRewardScRsp", "ElfRestaurantBuyFieldScRsp",
|
||||
"ElfRestaurantBuyShopItemScRsp", "ElfRestaurantFinishTradeOrderScRsp", "ElfRestaurantHarvestCropScRsp",
|
||||
"ElfRestaurantTakeVillagerRewardCsReq", "ElfRestaurantBuyFieldCsReq", "SetElfRestaurantPlayRecipeScRsp",
|
||||
"ElfRestaurantUpgradeFacilityLevelCsReq", "ElfRestaurantUpgradeRecipeLevelScRsp",
|
||||
"EnterElfRestaurantNextDayCsReq", "ElfRestaurantBuyShopItemCsReq",
|
||||
"ElfRestaurantUpgradeEmployeeLevelCsReq", "ElfRestaurantPlantSeedCsReq", "EnterElfRestaurantNextDayScRsp",
|
||||
"ElfRestaurantDataChangeNotify", "GetElfRestaurantDataCsReq", "ElfRestaurantClientStatusCsReq",
|
||||
"ElfRestaurantUpgradeFacilityLevelScRsp", "SettleElfRestaurantPlayScRsp", "ElfRestaurantRecycleSeedCsReq",
|
||||
"KFLIEGJGOBO", "IAOMNBIBCNP", "ElfRestaurantClientStatusScRsp", "ElfRestaurantHarvestCropCsReq",
|
||||
"TakeLoginActivityRewardCsReq", "KJPLKPNPFBE", "SubmitMaterialSubmitActivityMaterialCsReq",
|
||||
"GetMaterialSubmitActivityDataScRsp", "GetTrialActivityDataCsReq", "StartTrialActivityScRsp",
|
||||
"GetActivityScheduleConfigScRsp", "SubmitMaterialSubmitActivityMaterialScRsp",
|
||||
"GetMaterialSubmitActivityDataCsReq", "GetTrialActivityDataScRsp", "GetLoginActivityScRsp",
|
||||
"TakeRewardScRsp", "DKHBPNGPOKK", "GetLoginActivityCsReq", "LeaveTrialActivityCsReq",
|
||||
"TakeMaterialSubmitActivityRewardCsReq", "TakeTrialActivityRewardScRsp", "CurTrialActivityScNotify",
|
||||
"TakeTrialActivityRewardCsReq", "EnterTrialActivityStageCsReq", "GetActivityScheduleConfigCsReq",
|
||||
"StartTrialActivityCsReq", "ChooseAvatarScRsp", "TakeLoginActivityRewardScRsp",
|
||||
"TrialActivityDataChangeScNotify", "LeaveTrialActivityScRsp", "TakeMaterialSubmitActivityRewardScRsp",
|
||||
"ChooseAvatarCsReq", "PILLGGGIPAF", "EnterTrialActivityStageScRsp", "GetNpcStatusCsReq",
|
||||
"FinishMultiPerformSectionIdScRsp", "FinishPerformSectionIdScRsp", "FinishItemIdScRsp",
|
||||
"GetNpcMessageGroupScRsp", "GetNpcMessageGroupCsReq", "GetMissionMessageScRsp", "GetMissionMessageCsReq",
|
||||
"FinishSectionIdScRsp", "FinishSectionIdCsReq", "FinishItemIdCsReq", "FinishMultiPerformSectionIdCsReq",
|
||||
"GetNpcStatusScRsp", "FinishPerformSectionIdCsReq", "SetHeadIconCsReq", "SetPersonalCardCsReq",
|
||||
"SetSignatureCsReq", "SetDisplayAvatarScRsp", "GetPlayerBoardDataScRsp", "GetPlayerBoardDataCsReq",
|
||||
"SetAssistAvatarCsReq", "SetSignatureScRsp", "SetIsDisplayAvatarInfoScRsp", "SetIsDisplayAvatarInfoCsReq",
|
||||
"SetDisplayAvatarCsReq", "SetAssistAvatarScRsp", "SetPersonalCardScRsp", "SetHeadIconScRsp",
|
||||
"SetAssistCsReq", "ReportPlayerCsReq", "GetPlayerDetailInfoCsReq", "DeleteBlacklistScRsp",
|
||||
"DeleteFriendScRsp", "GetFriendAssistListCsReq", "GetFriendRecommendListInfoCsReq",
|
||||
"SyncAddBlacklistScNotify", "GetFriendAssistListScRsp", "GetFriendDevelopmentInfoScRsp",
|
||||
"HandleFriendCsReq", "GetFriendApplyListInfoScRsp", "GetFriendRecommendListInfoScRsp", "ReportPlayerScRsp",
|
||||
"AddBlacklistScRsp", "GetCurAssistCsReq", "GetPlatformPlayerInfoScRsp", "GetFriendListInfoScRsp",
|
||||
"SetForbidOtherApplyFriendScRsp", "SetAssistScRsp", "GetPlatformPlayerInfoCsReq",
|
||||
"GetFriendRecommendLineupDetailScRsp", "GetAssistHistoryScRsp", "GetFriendListInfoCsReq",
|
||||
"GetFriendRecommendLineupCsReq", "SetFriendRemarkNameCsReq", "SyncHandleFriendScNotify",
|
||||
"TakeAssistRewardCsReq", "CurAssistChangedNotify", "HandleFriendScRsp", "GetFriendBattleRecordDetailScRsp",
|
||||
"GetFriendRecommendLineupDetailCsReq", "GetFriendLoginInfoScRsp", "GetCurAssistScRsp", "SearchPlayerScRsp",
|
||||
"GetFriendBattleRecordDetailCsReq", "ApplyFriendScRsp", "ApplyFriendCsReq", "GetFriendApplyListInfoCsReq",
|
||||
"DeleteFriendCsReq", "GetFriendDevelopmentInfoCsReq", "AddBlacklistCsReq", "SetFriendMarkScRsp",
|
||||
"SyncDeleteFriendScNotify", "SetForbidOtherApplyFriendCsReq", "GetAssistListScRsp",
|
||||
"GetPlayerDetailInfoScRsp", "GetFriendRecommendLineupScRsp", "SetFriendRemarkNameScRsp", "ENEHBCOCEGB",
|
||||
"SetFriendMarkCsReq", "SearchPlayerCsReq", "GetAssistHistoryCsReq", "SyncApplyFriendScNotify",
|
||||
"NewAssistHistoryNotify", "DeleteBlacklistCsReq", "GetAssistListCsReq", "GetFriendLoginInfoCsReq",
|
||||
"BuyBpLevelCsReq", "BattlePassInfoNotify", "TakeAllRewardCsReq", "BuyBpLevelScRsp", "TakeBpRewardScRsp",
|
||||
"TakeBpRewardCsReq", "TakeAllRewardScRsp", "PlayBackGroundMusicCsReq", "UnlockBackGroundMusicScRsp",
|
||||
"GetJukeboxDataScRsp", "GetJukeboxDataCsReq", "EGDFPDHNOKO", "FKLMGEFILDI", "UnlockBackGroundMusicCsReq",
|
||||
"PlayBackGroundMusicScRsp", "PunkLordDataChangeNotify", "PunkLordBattleResultScNotify",
|
||||
"StartPunkLordRaidCsReq", "TakePunkLordPointRewardScRsp", "PunkLordRaidTimeOutScNotify",
|
||||
"SharePunkLordMonsterScRsp", "GetKilledPunkLordMonsterDataCsReq", "GetPunkLordDataScRsp",
|
||||
"GetPunkLordBattleRecordScRsp", "GetPunkLordMonsterDataScRsp", "GetPunkLordBattleRecordCsReq",
|
||||
"GetPunkLordMonsterDataCsReq", "PunkLordMonsterKilledNotify", "SummonPunkLordMonsterScRsp",
|
||||
"SummonPunkLordMonsterCsReq", "SharePunkLordMonsterCsReq", "TakePunkLordPointRewardCsReq",
|
||||
"GetPunkLordDataCsReq", "PunkLordMonsterInfoScNotify", "TakeKilledPunkLordMonsterScoreScRsp",
|
||||
"StartPunkLordRaidScRsp", "GetKilledPunkLordMonsterDataScRsp", "TakeKilledPunkLordMonsterScoreCsReq",
|
||||
"GetDailyActiveInfoCsReq", "TakeAllApRewardCsReq", "TakeApRewardScRsp", "TakeApRewardCsReq",
|
||||
"TakeAllApRewardScRsp", "DailyActiveInfoNotify", "GetDailyActiveInfoScRsp", "DailyFirstMeetPamCsReq",
|
||||
"GetRndOptionScRsp", "GetRndOptionCsReq", "DailyFirstMeetPamScRsp", "GetReplayTokenScRsp",
|
||||
"GetReplayTokenCsReq", "FightActivityDataChangeScNotify", "TakeFightActivityRewardCsReq",
|
||||
"GetFightActivityDataScRsp", "GetFightActivityDataCsReq", "TakeFightActivityRewardScRsp",
|
||||
"EnterFightActivityStageScRsp", "EnterFightActivityStageCsReq", "NPEBOLNHFAL",
|
||||
"TakeTrainVisitorBehaviorRewardScRsp", "TrainVisitorRewardSendNotify", "TrainVisitorBehaviorFinishScRsp",
|
||||
"TrainVisitorBehaviorFinishCsReq", "OBICGCEFCCI", "NNAHGDGNOEK", "GetTrainVisitorRegisterScRsp",
|
||||
"GetTrainVisitorRegisterCsReq", "TrainRefreshTimeNotify", "GetTrainVisitorBehaviorScRsp",
|
||||
"TakeTrainVisitorBehaviorRewardCsReq", "TextJoinQueryCsReq", "TextJoinBatchSaveScRsp", "TextJoinSaveScRsp",
|
||||
"KIJJCOBOJFM", "TextJoinBatchSaveCsReq", "TextJoinQueryScRsp", "RevcMsgScNotify",
|
||||
"BatchMarkChatEmojiScRsp", "GetChatEmojiListScRsp", "GetPrivateChatHistoryScRsp", "SendMsgScRsp",
|
||||
"SendMsgCsReq", "MarkChatEmojiScRsp", "MarkChatEmojiCsReq", "GetChatFriendHistoryScRsp",
|
||||
"GetChatFriendHistoryCsReq", "GetPrivateChatHistoryCsReq", "BatchMarkChatEmojiCsReq",
|
||||
"GetLoginChatInfoScRsp", "GetLoginChatInfoCsReq", "GetChatEmojiListCsReq", "SyncAcceptedPamMissionNotify",
|
||||
"AcceptedPamMissionExpireScRsp", "AcceptedPamMissionExpireCsReq", "DifficultyAdjustmentUpdateDataCsReq",
|
||||
"UpdateMovieRacingDataCsReq", "GetShareDataCsReq", "GetGunPlayDataScRsp", "TriggerVoiceScRsp",
|
||||
"CycleScoreRewardUpdateInfoScNotify", "GetOrigamiPropInfoScRsp", "GetPlayerForbidCsReq",
|
||||
"CancelAutoConversionCacheScRsp", "TakePictureScRsp", "UpdateMovieRacingDataScRsp",
|
||||
"GetOrigamiPropInfoCsReq", "MazeKillDirectCsReq", "GetPlayerForbidScRsp", "GetUnreleasedBlockInfoScRsp",
|
||||
"ShareScRsp", "CycleScoreRewardTakeRewardScRsp", "DifficultyAdjustmentUpdateDataScRsp",
|
||||
"GetUnreleasedBlockInfoCsReq", "CancelDirectDeliveryNoticeCsReq", "ShareCsReq", "GetMovieRacingDataCsReq",
|
||||
"SecurityReportScRsp", "GetSwitchMascotDataCsReq", "SwitchMascotUpdateScNotify", "IGBJDKIEDJL",
|
||||
"CycleScoreRewardGetDataScRsp", "MazeKillDirectScRsp", "UpdateGunPlayDataScRsp", "TakePictureCsReq",
|
||||
"TriggerVoiceCsReq", "SubmitOrigamiItemScRsp", "CycleScoreRewardDestroyExceedRewardScRsp",
|
||||
"SubmitOrigamiItemCsReq", "CycleScoreRewardTakeRewardCsReq", "DifficultyAdjustmentGetDataScRsp",
|
||||
"GetShareDataScRsp", "PlayerForbidScNotify", "GetMovieRacingDataScRsp", "GetSwitchMascotDataScRsp",
|
||||
"CycleScoreRewardDestroyExceedRewardCsReq", "UpdateGunPlayDataCsReq", "DirectDeliveryScNotify",
|
||||
"CancelAutoConversionCacheCsReq", "CancelDirectDeliveryNoticeScRsp", "GetGunPlayDataCsReq",
|
||||
"DifficultyAdjustmentGetDataCsReq", "CycleScoreRewardGetDataCsReq", "MatchBoxingClubOpponentCsReq",
|
||||
"SetBoxingClubResonanceScRsp", "BoxingClubChallengeUpdateScNotify", "StartBoxingClubBattleScRsp",
|
||||
"GetBoxingClubInfoScRsp", "GetBoxingClubInfoCsReq", "ChooseBoxingClubResonanceScRsp",
|
||||
"ChooseBoxingClubResonanceCsReq", "GiveUpBoxingClubChallengeScRsp", "GiveUpBoxingClubChallengeCsReq",
|
||||
"StartBoxingClubBattleCsReq", "SetBoxingClubResonanceCsReq", "ChooseBoxingClubStageOptionalBuffScRsp",
|
||||
"ChooseBoxingClubStageOptionalBuffCsReq", "MatchBoxingClubOpponentScRsp", "BoxingClubRewardScNotify",
|
||||
"MuseumRandomEventSelectScRsp", "BuyNpcStuffCsReq", "SyncMuseumTargetMissionFinishNotify",
|
||||
"UpgradeAreaScRsp", "SyncMuseumRandomEventStartScNotify", "SyncMuseumFundsChangedScNotify",
|
||||
"SyncGetExhibitScNotify", "SetStuffToAreaScRsp", "MuseumRandomEventQueryCsReq",
|
||||
"SyncMuseumInfoChangedScNotify", "GetMuseumInfoScRsp", "GetMuseumInfoCsReq", "MuseumRandomEventQueryScRsp",
|
||||
"FinishCurTurnScRsp", "FinishCurTurnCsReq", "TakeCollectRewardCsReq", "RemoveStuffFromAreaScRsp",
|
||||
"RemoveStuffFromAreaCsReq", "SetStuffToAreaCsReq", "UpgradeAreaCsReq", "UpgradeAreaStatScRsp",
|
||||
"UpgradeAreaStatCsReq", "BuyNpcStuffScRsp", "MuseumRandomEventSelectCsReq", "SyncMuseumTargetRewardNotify",
|
||||
"SyncGetStuffScNotify", "SyncMuseumTargetStartNotify", "TakeCollectRewardRsp", "QuitTreasureDungeonCsReq",
|
||||
"EnterTreasureDungeonScRsp", "InteractTreasureDungeonGridCsReq", "FightTreasureDungeonMonsterScRsp",
|
||||
"InteractTreasureDungeonGridScRsp", "QuitTreasureDungeonScRsp", "FightTreasureDungeonMonsterCsReq",
|
||||
"TreasureDungeonFinishScNotify", "TreasureDungeonDataScNotify", "UseTreasureDungeonItemCsReq",
|
||||
"GetTreasureDungeonActivityDataScRsp", "GetTreasureDungeonActivityDataCsReq", "EnterTreasureDungeonCsReq",
|
||||
"OpenTreasureDungeonGridScRsp", "OpenTreasureDungeonGridCsReq", "UseTreasureDungeonItemScRsp",
|
||||
"KFFEEDHEOOH", "PlayerReturnSignScRsp", "PlayerReturnTakeExtraHcoinScNotify",
|
||||
"TakeReturnExtraHcoinRewardCsReq", "EBCEGDPGHLE", "PlayerReturnTakePointRewardScRsp",
|
||||
"TakeReturnExtraHcoinRewardScRsp", "PlayerReturnSignCsReq", "PlayerReturnStartScNotify",
|
||||
"PlayerReturnExtraHcoinProgressScNotify", "PlayerReturnTakeRelicCsReq", "PlayerReturnForceFinishScNotify",
|
||||
"PlayerReturnTakeRewardScRsp", "PlayerReturnTakeRewardCsReq", "PlayerReturnTakePointRewardCsReq",
|
||||
"PlayerReturnTakeRelicScRsp", "PlayerReturnTakeCustomRelicScRsp", "PlayerReturnTakeCustomRelicCsReq",
|
||||
"PlayerReturnPointChangeScNotify", "CEINLELLFLF", "PlayerReturnInfoQueryCsReq", "MultipleDropInfoScNotify",
|
||||
"MultipleDropInfoNotify", "GetMultipleDropInfoScRsp", "GetMultipleDropInfoCsReq",
|
||||
"GetPlayerReturnMultiDropInfoScRsp", "GetPlayerReturnMultiDropInfoCsReq", "GetSaveLogisticsMapScRsp",
|
||||
"AlleyFundsScNotify", "LogisticsGameCsReq", "ENOABFIPHOB", "AlleyPlacingGameScRsp",
|
||||
"AlleyOrderChangedScNotify", "AlleyEventEffectNotify", "GetAlleyInfoScRsp", "ActivityRaidPlacingGameScRsp",
|
||||
"AlleyTakeEventRewardScRsp", "GetAlleyInfoCsReq", "PrestigeLevelUpCsReq", "TakePrestigeRewardScRsp",
|
||||
"LogisticsDetonateStarSkiffScRsp", "ActivityRaidPlacingGameCsReq", "TakePrestigeRewardCsReq",
|
||||
"LogisticsInfoScNotify", "StartAlleyEventScRsp", "StartAlleyEventCsReq", "AlleyPlacingGameCsReq",
|
||||
"GetSaveLogisticsMapCsReq", "LogisticsGameScRsp", "PrestigeLevelUpScRsp",
|
||||
"LogisticsScoreRewardSyncInfoScNotify", "SaveLogisticsScRsp", "AlleyTakeEventRewardCsReq",
|
||||
"AlleyEventChangeNotify", "LogisticsDetonateStarSkiffCsReq", "AlleyShipUnlockScNotify",
|
||||
"AlleyShipmentEventEffectsScNotify", "TakeOffSkillCoreScRsp", "GetAetherDivideChallengeInfoScRsp",
|
||||
"EnterAetherDivideSceneScRsp", "StartAetherDivideStageBattleCsReq", "SetAetherDivideLineUpScRsp",
|
||||
"AetherDivideSkillItemScNotify", "StartAetherDivideChallengeBattleScRsp",
|
||||
"GetAetherDivideChallengeInfoCsReq", "AetherDivideFinishChallengeScNotify", "GetAetherDivideInfoScRsp",
|
||||
"AetherDivideTainerInfoScNotify", "StartAetherDivideChallengeBattleCsReq", "AetherDivideLineupScNotify",
|
||||
"SwitchAetherDivideLineUpSlotCsReq", "StartAetherDivideStageBattleScRsp", "LeaveAetherDivideSceneCsReq",
|
||||
"AetherDivideSpiritInfoScNotify", "TakeOffSkillCoreCsReq", "AetherDivideTakeChallengeRewardScRsp",
|
||||
"EquipSkillCoreScRsp", "AetherDivideTakeChallengeRewardCsReq", "StartAetherDivideSceneBattleCsReq",
|
||||
"LeaveAetherDivideSceneScRsp", "StartAetherDivideSceneBattleScRsp", "AetherDivideRefreshEndlessScNotify",
|
||||
"BGPNJKENONA", "AetherDivideRefreshEndlessCsReq", "SwitchAetherDivideLineUpSlotScRsp",
|
||||
"AetherDivideSpiritExpUpScRsp", "SetAetherDivideLineUpCsReq", "AetherDivideSpiritExpUpCsReq",
|
||||
"AetherDivideRefreshEndlessScRsp", "EquipAetherDividePassiveSkillCsReq", "GetAetherDivideInfoCsReq",
|
||||
"GetBenefitActivityInfoScRsp", "TakeBenefitActivityRewardCsReq", "JoinBenefitActivityCsReq",
|
||||
"TakeBenefitActivityRewardScRsp", "JoinBenefitActivityScRsp", "GetBenefitActivityInfoCsReq",
|
||||
"FinishChapterScNotify", "FantasticStoryActivityBattleEndScNotify", "GetFantasticStoryActivityDataScRsp",
|
||||
"GetFantasticStoryActivityDataCsReq", "EnterFantasticStoryActivityStageScRsp",
|
||||
"EnterFantasticStoryActivityStageCsReq", "SelectChatBubbleCsReq", "SelectPhoneCaseScRsp",
|
||||
"SelectPhoneThemeCsReq", "GetPhoneDataScRsp", "GetPhoneDataCsReq", "UnlockPhoneCaseScNotify",
|
||||
"UnlockPhoneThemeScNotify", "SelectPhoneThemeScRsp", "UnlockChatBubbleScNotify", "SelectChatBubbleScRsp",
|
||||
"SelectPhoneCaseCsReq", "RogueModifierAddNotify", "RogueModifierStageStartNotify",
|
||||
"RogueModifierUpdateNotify", "RogueModifierSelectCellScRsp", "RogueModifierSelectCellCsReq",
|
||||
"RogueModifierDelNotify", "GetChessRogueStoryInfoCsReq", "ChessRogueChangeAeonDimensionNotify",
|
||||
"ChessRogueCheatRollScRsp", "GetChessRogueStoryInfoScRsp", "ChessRogueNousGetRogueTalentInfoCsReq",
|
||||
"ChessRogueLeaveScRsp", "ChessRogueEnterNextLayerScRsp", "SelectChessRogueSubStoryCsReq",
|
||||
"ChessRogueUpdateMoneyInfoScNotify", "ChessRogueStartCsReq", "ChessRogueSkipTeachingLevelCsReq",
|
||||
"ChessRogueConfirmRollScRsp", "EnterChessRogueAeonRoomCsReq", "ChessRogueQueryScRsp",
|
||||
"ChessRogueEnterCsReq", "ChessRogueNousDiceSurfaceUnlockNotify", "ChessRogueEnterNextLayerCsReq",
|
||||
"GBDPDGAHPNK", "SyncChessRogueNousValueScNotify", "BEAFKDMJNLO", "ChessRogueReRollDiceScRsp",
|
||||
"ChessRogueNousGetRogueTalentInfoScRsp", "ChessRogueLeaveCsReq", "ChessRogueQuitCsReq",
|
||||
"ChessRogueEnterCellCsReq", "JDKJBGGHDBM", "ChessRogueStartScRsp", "ChessRogueQuitScRsp", "FOAGKOAJEMD",
|
||||
"ChessRogueReRollDiceCsReq", "ChessRogueUpdateDicePassiveAccumulateValueScNotify", "ChessRogueEnterScRsp",
|
||||
"PBILBBMINBI", "FinishChessRogueSubStoryCsReq", "OKMIJLHMGCD", "ChessRogueSkipTeachingLevelScRsp",
|
||||
"GetChessRogueNousStoryInfoCsReq", "ChessRogueSelectCellScRsp", "ChessRogueUpdateDiceInfoScNotify",
|
||||
"SyncChessRogueNousSubStoryScNotify", "ChessRogueUpdateUnlockLevelScNotify", "IACEIJKMLLN",
|
||||
"ChessRogueNousDiceUpdateNotify", "ChessRogueUpdateActionPointScNotify",
|
||||
"GetChessRogueStoryAeonTalkInfoScRsp", "ChessRogueQuestFinishNotify", "ChessRogueGiveUpScRsp",
|
||||
"SelectChessRogueSubStoryScRsp", "GetChessRogueNousStoryInfoScRsp", "EnterChessRogueAeonRoomScRsp",
|
||||
"SyncChessRogueNousMainStoryScNotify", "ChessRogueCheatRollCsReq", "ChessRogueSelectCellCsReq",
|
||||
"ChessRogueRollDiceScRsp", "ChessRogueEnterCellScRsp", "ChessRogueLayerSettlement", "ChessRogueQueryCsReq",
|
||||
"FinishChessRogueSubStoryScRsp", "ChessRogueUpdateAllowedSelectCellScNotify", "ChessRogueConfirmRollCsReq",
|
||||
"ChessRogueNousEnableRogueTalentCsReq", "DJHNHHCMKMF", "ChessRogueNousEnableRogueTalentScRsp",
|
||||
"ChessRogueGiveUpCsReq", "ChessRogueRollDiceCsReq", "ChessRogueUpdateReviveInfoScNotify",
|
||||
"ChessRogueUpdateAeonModifierValueScNotify", "LOINLLCCNBD", "ChessRogueCellUpdateNotify",
|
||||
"SyncChessRogueMainStoryFinishScNotify", "ChessRogueUpdateLevelBaseInfoScNotify",
|
||||
"GetChessRogueStoryAeonTalkInfoCsReq", "ChessRogueNousEditDiceScRsp", "DCADHJHJMMD", "MCHIJNKKOAM",
|
||||
"CEDCDDPHBOG", "RogueWorkbenchGetInfoCsReq", "GetRogueCommonDialogueDataScRsp", "LECIGPPCMAB",
|
||||
"GetRogueShopBuffInfoCsReq", "TakeEventHandbookRewardCsReq", "LNKEEAKFEHM", "JLJNNPIAKGH",
|
||||
"RogueWorkbenchGetInfoScRsp", "SetRogueCollectionScRsp", "TakeRogueMiracleHandbookRewardScRsp",
|
||||
"LPDPAJGFBMM", "PJFPIPIPOJN", "SelectRogueCommonDialogueOptionCsReq", "SetRogueCollectionCsReq",
|
||||
"MJEPCLPMKKI", "GetRogueHandbookDataScRsp", "TakeRogueMiracleHandbookRewardCsReq", "BuyRogueShopBuffScRsp",
|
||||
"CommonRogueUpdateScNotify", "BIHACLHMAKO", "SyncRogueCommonActionResultScNotify",
|
||||
"SetRogueExhibitionCsReq", "CommonRogueQueryScRsp", "GetRogueShopFormulaInfoScRsp", "RogueDoGambleScRsp",
|
||||
"BuyRogueShopMiracleCsReq", "HandleRogueCommonPendingActionScRsp", "GetRogueShopBuffInfoScRsp",
|
||||
"RogueShopBeginBattleCsReq", "GetRogueShopMiracleInfoScRsp", "GKNPEGHPCMH",
|
||||
"GetRogueCommonDialogueDataCsReq", "RogueDoGambleCsReq", "RogueGetGambleInfoCsReq",
|
||||
"CommonRogueVirtualItemInfoScNotify", "CKPKIINILKA", "SyncRogueCommonPendingActionScNotify", "OMAOGCPKDNB",
|
||||
"BuyRogueShopFormulaScRsp", "GetRogueShopMiracleInfoCsReq", "OFEDNNJIJFL",
|
||||
"SelectRogueCommonDialogueOptionScRsp", "SyncRogueHandbookDataUpdateScNotify", "FLCPMJGCHID",
|
||||
"BuyRogueShopMiracleScRsp", "GetRogueHandbookDataCsReq", "EDBOGEKADGP", "RogueGetGambleInfoScRsp",
|
||||
"SyncRogueCommonDialogueOptionFinishScNotify", "RogueShopBeginBattleScRsp", "GetRogueShopFormulaInfoCsReq",
|
||||
"SyncRogueCommonDialogueDataScNotify", "RogueWorkbenchHandleFuncScRsp", "TakeEventHandbookRewardScRsp",
|
||||
"CommonRogueQueryCsReq", "SetRogueExhibitionScRsp", "RogueWorkbenchHandleFuncCsReq",
|
||||
"SyncBattleCollegeDataChangeScNotify", "GetBattleCollegeDataScRsp", "GetBattleCollegeDataCsReq",
|
||||
"StartBattleCollegeScRsp", "StartBattleCollegeCsReq", "HeliobusStartRaidScRsp", "HeliobusSnsReadCsReq",
|
||||
"HeliobusUpgradeLevelScRsp", "HeliobusSnsCommentScRsp", "HeliobusSnsPostScRsp", "HeliobusEnterBattleCsReq",
|
||||
"HeliobusChallengeUpdateScNotify", "HeliobusSelectSkillScRsp", "HeliobusActivityDataScRsp",
|
||||
"HeliobusActivityDataCsReq", "HeliobusEnterBattleScRsp", "HeliobusInfoChangedScNotify",
|
||||
"HeliobusSnsUpdateScNotify", "HeliobusSnsLikeScRsp", "HeliobusSnsLikeCsReq", "HeliobusSnsPostCsReq",
|
||||
"HeliobusUpgradeLevelCsReq", "HeliobusSelectSkillCsReq", "HeliobusUnlockSkillScNotify",
|
||||
"HeliobusSnsReadScRsp", "HeliobusStartRaidCsReq", "HeliobusSnsCommentCsReq",
|
||||
"HeliobusLineupUpdateScNotify", "GetAllRedDotDataScRsp", "NNCEFFFGNEN", "ILDOMDOFEPK",
|
||||
"UpdateRedDotDataScRsp", "GetSingleRedDotParamGroupScRsp", "GetAllRedDotDataCsReq", "FateShopLeaveScRsp",
|
||||
"FateShopBuyGoodsScRsp", "FateTakeExpRewardScRsp", "FateQueryScRsp", "FateSyncActionResultScNotify",
|
||||
"FateShopRefreshGoodsScRsp", "FateChangeLineupScRsp", "FateTakeExpRewardCsReq", "FateShopSellBuffScRsp",
|
||||
"FateChangeLineupCsReq", "FateHandlePendingActionCsReq", "FateSyncPendingActionScNotify", "FateStartCsReq",
|
||||
"FateSettleScNotify", "FateShopLeaveCsReq", "FateShopLockGoodsScRsp", "FateShopBuyGoodsCsReq",
|
||||
"FateBattleStartCsReq", "FateStartScRsp", "FateBattleStartScRsp", "FateQueryCsReq",
|
||||
"FateHandlePendingActionScRsp", "FateSettleScRsp", "FateShopRefreshGoodsCsReq", "FateSettleCsReq",
|
||||
"FateShopLockGoodsCsReq", "FateShopSellBuffCsReq", "TakeRogueEndlessActivityAllBonusRewardScRsp",
|
||||
"GetRogueEndlessActivityDataCsReq", "GetRogueEndlessActivityDataScRsp",
|
||||
"RogueEndlessActivityBattleEndScNotify", "TakeRogueEndlessActivityPointRewardScRsp",
|
||||
"TakeRogueEndlessActivityPointRewardCsReq", "TakeRogueEndlessActivityAllBonusRewardCsReq",
|
||||
"EnterRogueEndlessActivityStageScRsp", "EnterRogueEndlessActivityStageCsReq",
|
||||
"RogueTournGetAllBuildRefCsReq", "RogueTournLeaveCsReq", "RogueTournReviveCostUpdateScNotify",
|
||||
"RogueTournQueryScRsp", "RogueTournStartCsReq", "RogueTournEnterRoomScRsp",
|
||||
"RogueTournGetArchiveRepositoryScRsp", "RogueTournResetPermanentTalentCsReq", "RogueTournEnterScRsp",
|
||||
"RogueTournGetCurRogueCocoonInfoCsReq", "RogueTournBattleFailSettleInfoScNotify",
|
||||
"RogueTournLevelInfoUpdateScNotify", "RogueTournReviveAvatarCsReq", "RogueTournRenameArchiveCsReq",
|
||||
"RogueTournUseSuperRewardKeyCsReq", "RogueTournQueryCsReq", "RogueTournGetAllArchiveScRsp",
|
||||
"RogueTournSettleCsReq", "RogueTournGetCurRogueCocoonInfoScRsp", "RogueTournEnterRogueCocoonSceneScRsp",
|
||||
"RogueTournEnableSeasonTalentScRsp", "RogueTournReEnterRogueCocoonStageScRsp", "RogueTournHandBookNotify",
|
||||
"RogueTournDeleteBuildRefCsReq", "RogueTournDeleteArchiveCsReq", "RogueTournEnterRogueCocoonSceneCsReq",
|
||||
"RogueTournGetArchiveRepositoryCsReq", "RogueTournClearArchiveNameScNotify", "JGFPIJKOFCC",
|
||||
"RogueTournDeleteArchiveScRsp", "RogueTournTriggerRoomSelectScRsp", "RogueTournGetAllBuildRefScRsp",
|
||||
"RogueTournSaveBuildRefScRsp", "RogueTournLeaveScRsp", "RogueTournGetSeasonTalentInfoScRsp",
|
||||
"RogueTournEnableSeasonTalentCsReq", "RogueTournTakeExpRewardScRsp", "RogueTournStartScRsp",
|
||||
"RogueTournGetSeasonTalentInfoCsReq", "RogueTournRenameBuildRefScRsp",
|
||||
"RogueTournGetMiscRealTimeDataScRsp", "RogueTournGetSettleInfoCsReq",
|
||||
"RogueTournEnablePermanentTalentScRsp", "RogueTournDeleteBuildRefScRsp", "RogueTournSaveBuildRefCsReq",
|
||||
"RogueTournUseSuperRewardKeyScRsp", "RogueTournResetPermanentTalentScRsp", "RogueTournConfirmSettleCsReq",
|
||||
"RogueTournLeaveRogueCocoonSceneScRsp", "RogueTournGetPermanentTalentInfoCsReq",
|
||||
"RogueTournConfirmSettleScRsp", "RogueTournEnablePermanentTalentCsReq",
|
||||
"RogueTournGetMiscRealTimeDataCsReq", "RogueTournTitanUpdateTitanBlessProgressScNotify",
|
||||
"RogueTournReEnterRogueCocoonStageCsReq", "RogueTournGetPermanentTalentInfoScRsp",
|
||||
"RogueTournEnterLayerScRsp", "RogueTournPersonaRoomCardZoneUpdateScNotify", "RogueTournExpNotify",
|
||||
"PALAOPCBAPC", "RogueTournRenameArchiveScRsp", "RogueTournEnterLayerCsReq", "RogueTournEnterRoomCsReq",
|
||||
"RogueTournSettleScRsp", "RogueTournWeekChallengeUpdateScNotify", "RogueTournEnterCsReq",
|
||||
"RogueTournRenameBuildRefCsReq", "RogueTournAreaUpdateScNotify", "RogueTournGetAllArchiveCsReq",
|
||||
"RogueTournReviveAvatarScRsp", "RogueTournTakeExpRewardCsReq", "RogueTournTriggerRoomSelectCsReq",
|
||||
"RogueTournGetSettleInfoScRsp", "RogueTournLeaveRogueCocoonSceneCsReq", "IANCKAHGKMI",
|
||||
"UpdateServerPrefsDataScRsp", "GetAllServerPrefsDataScRsp", "GetAllServerPrefsDataCsReq",
|
||||
"UpdateServerPrefsCsReq", "GetServerPrefsDataScRsp", "StoryLineInfoScNotify",
|
||||
"ChangeStoryLineFinishScNotify", "GetStoryLineInfoScRsp", "GetStoryLineInfoCsReq",
|
||||
"StoryLineTrialAvatarChangeScNotify", "ChangeScriptEmotionCsReq", "HeartDialTraceScriptCsReq",
|
||||
"SubmitEmotionItemScRsp", "GetHeartDialInfoScRsp", "GetHeartDialInfoCsReq", "HeartDialTraceScriptScRsp",
|
||||
"FinishEmotionDialoguePerformanceScRsp", "FinishEmotionDialoguePerformanceCsReq", "SubmitEmotionItemCsReq",
|
||||
"ChangeScriptEmotionScRsp", "HeartDialScriptChangeScNotify", "TravelBrochurePageUnlockScNotify",
|
||||
"TravelBrochurePageResetCsReq", "TravelBrochureSetPageDescStatusScRsp", "TravelBrochureRemovePasterScRsp",
|
||||
"TravelBrochureSelectMessageScRsp", "TravelBrochurePageResetScRsp", "TravelBrochureSetPageDescStatusCsReq",
|
||||
"TravelBrochureGetDataScRsp", "TravelBrochureGetDataCsReq", "TravelBrochureApplyPasterListCsReq",
|
||||
"TravelBrochureUpdatePasterPosScRsp", "TravelBrochureUpdatePasterPosCsReq",
|
||||
"TravelBrochureSelectMessageCsReq", "TravelBrochureGetPasterScNotify", "TravelBrochureSetCustomValueScRsp",
|
||||
"TravelBrochureSetCustomValueCsReq", "TravelBrochureApplyPasterListScRsp",
|
||||
"TravelBrochureRemovePasterCsReq", "LBMFCJLHNAA", "GetWolfBroGameDataScRsp", "StartWolfBroGameScRsp",
|
||||
"WolfBroGamePickupBulletCsReq", "QuitWolfBroGameScRsp", "OKEFGCNNEKI", "QuitWolfBroGameCsReq",
|
||||
"WolfBroGameExplodeMonsterScRsp", "ArchiveWolfBroGameCsReq", "WolfBroGameActivateBulletScRsp",
|
||||
"WolfBroGameActivateBulletCsReq", "GetWolfBroGameDataCsReq", "RestoreWolfBroGameArchiveCsReq",
|
||||
"ArchiveWolfBroGameScRsp", "RestoreWolfBroGameArchiveScRsp", "StartWolfBroGameCsReq",
|
||||
"WolfBroGameUseBulletScRsp", "WolfBroGamePickupBulletScRsp", "WolfBroGameDataChangeScNotify",
|
||||
"ResetEraFlipperDataCsReq", "EnterEraFlipperDataScRsp", "ChangeEraFlipperDataScRsp",
|
||||
"EraFlipperDataChangeScNotify", "EnterEraFlipperDataCsReq", "ChangeEraFlipperDataCsReq",
|
||||
"GetEraFlipperDataScRsp", "ResetEraFlipperDataScRsp", "GetEraFlipperDataCsReq",
|
||||
"LocalLegendDataChangeNotify", "EnterLocalLegendLevelScRsp", "EnterLocalLegendLevelCsReq",
|
||||
"GetLocalLegendDataScRsp", "GetLocalLegendDataCsReq", "EnterStrongChallengeActivityStageCsReq",
|
||||
"GetStrongChallengeActivityDataScRsp", "GetStrongChallengeActivityDataCsReq",
|
||||
"StrongChallengeActivityBattleEndScNotify", "EnterStrongChallengeActivityStageScRsp", "SpaceZooBornCsReq",
|
||||
"SpaceZooTakeCsReq", "SpaceZooDeleteCatScRsp", "SpaceZooMutateScRsp", "SpaceZooDataScRsp",
|
||||
"SpaceZooDataCsReq", "SpaceZooExchangeItemCsReq", "SpaceZooCatUpdateNotify", "SpaceZooOpCatteryScRsp",
|
||||
"SpaceZooOpCatteryCsReq", "SpaceZooMutateCsReq", "SpaceZooExchangeItemScRsp", "SpaceZooTakeScRsp",
|
||||
"SpaceZooBornScRsp", "SpaceZooDeleteCatCsReq", "ICBEPDGBHMM", "ResetMapRotationRegionScRsp",
|
||||
"RemoveRotaterScRsp", "EEOOKOOABJD", "LeaveMapRotationRegionScRsp", "DeployRotatorScRsp",
|
||||
"UpdateRotaterScNotify", "UpdateMapRotationDataScNotify", "EnterMapRotationRegionScRsp",
|
||||
"EnterMapRotationRegionCsReq", "GetMapRotationDataScRsp", "GetMapRotationDataCsReq", "RotateMapScRsp",
|
||||
"RotateMapCsReq", "ELJDDNGDGCH", "ResetMapRotationRegionCsReq", "UpdateEnergyScNotify",
|
||||
"InteractChargerScRsp", "LeaveMapRotationRegionCsReq", "TakeRollShopRewardCsReq", "DoGachaInRollShopScRsp",
|
||||
"GetRollShopInfoCsReq", "TakeRollShopRewardScRsp", "GetRollShopInfoScRsp", "DoGachaInRollShopCsReq",
|
||||
"TakeOfferingRewardCsReq", "SubmitOfferingItemScRsp", "GetOfferingInfoCsReq", "TakeOfferingRewardScRsp",
|
||||
"GetOfferingInfoScRsp", "SubmitOfferingItemCsReq", "OfferingInfoScNotify",
|
||||
"RaidCollectionEnterNextRaidScRsp", "RaidCollectionEnterNextRaidCsReq", "RaidCollectionDataCsReq",
|
||||
"RaidCollectionDataScRsp", "EnterTelevisionActivityStageScRsp", "EnterTelevisionActivityStageCsReq",
|
||||
"GetTelevisionActivityDataCsReq", "TelevisionActivityBattleEndScNotify", "GetTelevisionActivityDataScRsp",
|
||||
"DrinkMakerCheersMakeDrinkScRsp", "DrinkMakerCheersGetDataCsReq", "EndDrinkMakerSequenceCsReq",
|
||||
"MakeMissionDrinkScRsp", "DrinkMakerUpdateTipsNotify", "DrinkMakerChallengeScRsp", "MakeDrinkScRsp",
|
||||
"DrinkMakerChallengeCsReq", "GetDrinkMakerDataCsReq", "GetDrinkMakerDayEndScNotify",
|
||||
"EndDrinkMakerSequenceScRsp", "DrinkMakerCheersEnterNextGroupCsReq", "DrinkMakerCheersEnterNextGroupScRsp",
|
||||
"GetDrinkMakerDataScRsp", "MakeDrinkCsReq", "DrinkMakerCheersMakeDrinkCsReq",
|
||||
"DrinkMakerCheersGetDataScRsp", "MakeMissionDrinkCsReq", "MonopolyConditionUpdateScNotify",
|
||||
"MonopolyGameRaiseRatioScRsp", "MonopolyUpgradeAssetCsReq", "MonopolyActionResultScNotify",
|
||||
"MonopolyClickMbtiReportCsReq", "MonopolyGiveUpCurContentScRsp", "DailyFirstEnterMonopolyActivityScRsp",
|
||||
"MonopolyGetRaffleTicketCsReq", "MonopolyConfirmRandomCsReq", "MonopolyReRollRandomScRsp",
|
||||
"MonopolyDailySettleScNotify", "MonopolyEventSelectFriendCsReq", "MonopolySelectOptionCsReq",
|
||||
"MonopolyRollDiceCsReq", "MonopolyConfirmRandomScRsp", "MonopolyGetRafflePoolInfoScRsp",
|
||||
"MonopolyUpgradeAssetScRsp", "MonopolyGetDailyInitItemScRsp", "MonopolyReRollRandomCsReq",
|
||||
"MonopolyGameGachaCsReq", "BBEPMABCBGF", "IAFJANHGCLL", "GetMonopolyInfoScRsp",
|
||||
"MonopolyGetRaffleTicketScRsp", "MonopolyScrachRaffleTicketScRsp", "MonopolyGameSettleScNotify",
|
||||
"MonopolyGuessChooseScRsp", "GetSocialEventServerCacheScRsp", "MonopolyGameBingoFlipCardScRsp",
|
||||
"MonopolySttUpdateScNotify", "GetMonopolyInfoCsReq", "MonopolyEventLoadUpdateScNotify",
|
||||
"MonopolyEventSelectFriendScRsp", "MonopolyScrachRaffleTicketCsReq", "MonopolyBuyGoodsCsReq",
|
||||
"MonopolyAcceptQuizScRsp", "MonopolyGuessChooseCsReq", "MonopolySelectOptionScRsp", "GetMbtiReportCsReq",
|
||||
"MonopolyGetRafflePoolInfoCsReq", "GetSocialEventServerCacheCsReq", "MonopolyGuessDrawScNotify",
|
||||
"MonopolyGetDailyInitItemCsReq", "MonopolyGameGachaScRsp", "MonopolyGetRegionProgressCsReq",
|
||||
"MonopolyCheatDiceScRsp", "MonopolyTakePhaseRewardCsReq", "MonopolyLikeScNotify", "MonopolyClickCellScRsp",
|
||||
"MonopolyMoveCsReq", "MonopolyLikeCsReq", "MonopolyRollDiceScRsp", "DailyFirstEnterMonopolyActivityCsReq",
|
||||
"GetMbtiReportScRsp", "MonopolyRollRandomScRsp", "MonopolyClickCellCsReq", "GetMonopolyDailyReportCsReq",
|
||||
"MonopolyRollRandomCsReq", "GetMonopolyMbtiReportRewardScRsp", "MonopolyQuizDurationChangeScNotify",
|
||||
"GetMonopolyMbtiReportRewardCsReq", "MonopolyGameRaiseRatioCsReq", "MonopolyCellUpdateNotify",
|
||||
"GetMonopolyFriendRankingListScRsp", "MonopolySocialEventEffectScNotify", "MonopolyBuyGoodsScRsp",
|
||||
"MonopolyGameCreateScNotify", "MonopolyCheatDiceCsReq", "MonopolyGameBingoFlipCardCsReq",
|
||||
"MonopolyMoveScRsp", "MonopolyAcceptQuizCsReq", "MonopolyGiveUpCurContentCsReq",
|
||||
"GetMonopolyDailyReportScRsp", "DeleteSocialEventServerCacheScRsp", "MonopolyClickMbtiReportScRsp",
|
||||
"DeleteSocialEventServerCacheCsReq", "MonopolyContentUpdateScNotify", "MonopolyTakePhaseRewardScRsp",
|
||||
"MonopolyTakeRaffleTicketRewardScRsp", "MonopolyGuessBuyInformationScRsp", "MonopolyLikeScRsp",
|
||||
"MonopolyGetRegionProgressScRsp", "MonopolyTakeRaffleTicketRewardCsReq",
|
||||
"GetEvolveBuildShopAbilityDownCsReq", "EvolveBuildSkipTeachLevelScRsp", "GetEvolveBuildQueryInfoScRsp",
|
||||
"GetEvolveBuildGiveupScRsp", "EvolveBuildSkipTeachLevelCsReq", "GetEvolveBuildReRandomStageCsReq",
|
||||
"GetEvolveBuildGiveupCsReq", "GetEvolveBuildShopAbilityDownScRsp", "GetEvolveBuildShopAbilityResetCsReq",
|
||||
"EvolveBuildStartLevelCsReq", "GetEvolveBuildShopAbilityUpScRsp", "GetEvolveBuildShopAbilityUpCsReq",
|
||||
"EvolveBuildStartStageCsReq", "EvolveBuildStartLevelScRsp", "EvolveBuildStartStageScRsp",
|
||||
"GetEvolveBuildQueryInfoCsReq", "GetEvolveBuildCoinNotify", "GetEvolveBuildReRandomStageScRsp",
|
||||
"GetEvolveBuildShopAbilityResetScRsp", "GetEvolveBuildFinishScNotify", "EnterFeverTimeActivityStageCsReq",
|
||||
"EnterFeverTimeActivityStageScRsp", "GetFeverTimeActivityDataScRsp", "GetFeverTimeActivityDataCsReq",
|
||||
"FeverTimeActivityBattleEndScNotify", "GetStarFightDataCsReq", "GetStarFightDataScRsp",
|
||||
"StarFightDataChangeNotify", "StartStarFightLevelScRsp", "StartStarFightLevelCsReq", "PEMMJHDFOLM",
|
||||
"GetBadgeInfoScRsp", "BadgeInfoScNotify", "ClockParkGetOngoingScriptInfoScRsp", "ClockParkGetInfoScRsp",
|
||||
"ClockParkFinishScriptScNotify", "ClockParkQuitScriptScRsp", "ClockParkStartScriptScRsp",
|
||||
"ClockParkHandleWaitOperationScRsp", "ClockParkStartScriptCsReq", "ClockParkUseBuffCsReq",
|
||||
"ClockParkBattleEndScNotify", "ClockParkGetOngoingScriptInfoCsReq", "ClockParkUnlockTalentCsReq",
|
||||
"ClockParkUnlockTalentScRsp", "ClockParkGetInfoCsReq", "ClockParkUseBuffScRsp", "ClockParkQuitScriptCsReq",
|
||||
"ClockParkHandleWaitOperationCsReq", "GetFightFestDataScRsp", "FightFestUpdateCoinNotify",
|
||||
"FightFestUpdateChallengeRecordNotify", "StartFightFestCsReq", "FightFestScoreUpdateNotify",
|
||||
"StartFightFestScRsp", "FightFestUnlockSkillNotify", "GetFightFestDataCsReq", "StartMatchScRsp",
|
||||
"GetCrossInfoScRsp", "CancelMatchCsReq", "CancelMatchScRsp", "GetCrossInfoCsReq", "StartMatchCsReq",
|
||||
"LobbyGetInfoScRsp", "LobbyKickOutScRsp", "LobbyCreateScRsp", "LobbyInteractScNotify", "LobbyQuitCsReq",
|
||||
"GHGECAINBLA", "LobbyInviteCsReq", "LobbyModifyPlayerInfoCsReq", "LobbyInteractCsReq", "ANFMHBGNNEG",
|
||||
"LobbyJoinCsReq", "LobbyGetInfoCsReq", "LobbyKickOutCsReq", "LobbyStartFightCsReq", "LobbyJoinScRsp",
|
||||
"GJBGNMGGDLE", "LobbyCreateCsReq", "LobbyInteractScRsp", "LobbyInviteScRsp", "KIGJGMLAMGH", "JDHEDGIGKBM",
|
||||
"LobbySyncInfoScNotify", "MatchThreeRoyaleFinishScNotify", "MatchThreeV2BattleItemLevelUpCsReq",
|
||||
"MatchThreeGetDataDataScRsp", "MatchThreeV2SetBirdPosCsReq", "PBDKDFCOAPB", "MatchThreeV2LevelEndCsReq",
|
||||
"MatchThreeSetBirdPosScRsp", "MatchThreeLevelEndCsReq", "MatchThreeV2PvpFinishScNotify",
|
||||
"MatchThreeV2ScNotify", "DHFGMHFEHEB", "MatchThreeSyncDataScNotify", "MatchThreeLevelEndScRsp",
|
||||
"MatchThreeSetBirdPosCsReq", "JPDGPEOBBNJ", "MatchThreeV2LevelEndScRsp", "MatchThreeV2SetBirdPosScRsp",
|
||||
"MatchThreeV2BattleItemLevelUpScRsp", "SwordTrainingSetSkillTraceCsReq", "SwordTrainingStartGameCsReq",
|
||||
"SwordTrainingStoryBattleCsReq", "GetSwordTrainingDataCsReq", "SwordTrainingStoryConfirmScRsp",
|
||||
"EnterSwordTrainingExamCsReq", "SwordTrainingSelectEndingScRsp", "SwordTrainingDialogueSelectOptionCsReq",
|
||||
"SwordTrainingUnlockSyncScNotify", "SwordTrainingSelectEndingCsReq", "SwordTrainingDailyPhaseConfirmScRsp",
|
||||
"SwordTrainingStartGameScRsp", "SwordTrainingGiveUpGameCsReq", "GetSwordTrainingDataScRsp",
|
||||
"SwordTrainingGameSettleScNotify", "SwordTrainingSetSkillTraceScRsp", "SwordTrainingLearnSkillScRsp",
|
||||
"SwordTrainingExamResultConfirmScRsp", "SwordTrainingLearnSkillCsReq",
|
||||
"SwordTrainingDialogueSelectOptionScRsp", "SwordTrainingStoryBattleScRsp",
|
||||
"SwordTrainingExamResultConfirmCsReq", "SwordTrainingTurnActionScRsp", "SwordTrainingTurnActionCsReq",
|
||||
"SwordTrainingDailyPhaseConfirmCsReq", "SwordTrainingActionTurnSettleScNotify",
|
||||
"SwordTrainingGameSyncChangeScNotify", "SwordTrainingRestoreGameCsReq", "SwordTrainingResumeGameCsReq",
|
||||
"SwordTrainingResumeGameScRsp", "SwordTrainingStoryConfirmCsReq", "SwordTrainingGiveUpGameScRsp",
|
||||
"SwordTrainingRestoreGameScRsp", "SwordTrainingMarkEndingViewedCsReq", "EnterSwordTrainingExamScRsp",
|
||||
"SwordTrainingMarkEndingViewedScRsp", "ContentPackageGetDataScRsp", "ContentPackageSyncDataScNotify",
|
||||
"ContentPackageTransferScNotify", "ContentPackageGetDataCsReq", "GetTrackPhotoActivityDataCsReq",
|
||||
"GetTrackPhotoActivityDataScRsp", "StartTrackPhotoStageCsReq", "QuitTrackPhotoStageCsReq",
|
||||
"StartTrackPhotoStageScRsp", "QuitTrackPhotoStageScRsp", "SettleTrackPhotoStageScRsp",
|
||||
"SettleTrackPhotoStageCsReq", "GetSummonActivityDataCsReq", "GetSummonActivityDataScRsp",
|
||||
"SummonActivityBattleEndScNotify", "EnterSummonActivityStageScRsp", "EnterSummonActivityStageCsReq",
|
||||
"MusicRhythmStartLevelScRsp", "MusicRhythmUnlockTrackScNotify", "MusicRhythmFinishLevelScRsp",
|
||||
"MusicRhythmDataCsReq", "MusicRhythmSaveSongConfigDataCsReq", "MusicRhythmStartLevelCsReq",
|
||||
"MusicRhythmUnlockSongSfxScNotify", "MusicRhythmMaxDifficultyLevelsUnlockNotify",
|
||||
"MusicRhythmFinishLevelCsReq", "MusicRhythmUnlockSongNotify", "MusicRhythmSaveSongConfigDataScRsp",
|
||||
"MusicRhythmDataScRsp", "UpgradeBlindBoxPetChromaCsReq", "RecallPetCsReq", "SetBlindBoxPetLineupCsReq",
|
||||
"RecycleBlindBoxPetScRsp", "RecycleBlindBoxPetCsReq", "SummonPetScRsp", "SwitchBlindBoxPetChromaScRsp",
|
||||
"SetBlindBoxPetLineupScRsp", "PetDataChangeScNotify", "SummonPetCsReq", "GetPetDataScRsp",
|
||||
"SwitchBlindBoxPetChromaCsReq", "RecallPetScRsp", "UpgradeBlindBoxPetChromaScRsp", "GetPetDataCsReq",
|
||||
"WorldUnlockCsReq", "WorldUnlockScRsp", "RogueArcadeStartScRsp", "RogueArcadeGetInfoScRsp",
|
||||
"RogueArcadeGetInfoCsReq", "RogueArcadeLeaveCsReq", "RogueArcadeRestartCsReq", "RogueArcadeLeaveScRsp",
|
||||
"RogueArcadeRestartScRsp", "RogueArcadeStartCsReq", "RogueMagicQueryScRsp", "RogueMagicEnterCsReq",
|
||||
"RogueMagicScepterDressInUnitScRsp", "RogueMagicAreaUpdateScNotify", "RogueMagicReviveCostUpdateScNotify",
|
||||
"RogueMagicBattleFailSettleInfoScNotify", "RogueMagicEnterRoomScRsp", "RogueMagicLeaveScRsp",
|
||||
"RogueMagicReviveAvatarCsReq", "RogueMagicAutoDressInUnitScRsp", "RogueMagicStartScRsp",
|
||||
"RogueMagicAutoDressInUnitCsReq", "RogueMagicEnableTalentScRsp", "RogueMagicStartCsReq",
|
||||
"RogueMagicReviveAvatarScRsp", "RogueMagicEnterLayerScRsp", "RogueMagicSetAutoDressInMagicUnitScRsp",
|
||||
"RogueMagicGetMiscRealTimeDataScRsp", "RogueMagicEnterLayerCsReq",
|
||||
"RogueMagicAutoDressInMagicUnitChangeScNotify", "RogueMagicScepterTakeOffUnitScRsp",
|
||||
"RogueMagicSettleScRsp", "RogueMagicSettleCsReq", "RogueMagicLeaveCsReq",
|
||||
"RogueMagicLevelInfoUpdateScNotify", "RogueMagicUnitComposeScRsp", "RogueMagicEnterScRsp",
|
||||
"RogueMagicQueryCsReq", "RogueMagicGetMiscRealTimeDataCsReq", "RogueMagicScepterTakeOffUnitCsReq",
|
||||
"RogueMagicEnableTalentCsReq", "RogueMagicEnterRoomCsReq", "RogueMagicSetAutoDressInMagicUnitCsReq",
|
||||
"RogueMagicScepterDressInUnitCsReq", "RogueMagicUnitComposeCsReq", "RogueMagicStoryInfoUpdateScNotify",
|
||||
"TrainPartyUseCardCsReq", "TrainPartyBuildStartStepScRsp", "TrainPartyEnterCsReq", "FDDJJCFNFOJ",
|
||||
"TrainPartyBuildingUpdateNotify", "TrainPartyTakeBuildLevelAwardCsReq", "TrainPartyGetDataScRsp",
|
||||
"TrainPartySetBadgeAutoFillCsReq", "DNIAFCGKEOP", "TrainPartyGetDataCsReq", "TrainPartyLeaveCsReq",
|
||||
"TrainPartyHandlePendingActionScRsp", "TrainPartyHandlePendingActionCsReq",
|
||||
"TrainPartyBuildSelfDisplayAreaDiyScRsp", "TrainPartyTakeBuildLevelAwardScRsp",
|
||||
"TrainPartyGamePlayStartScRsp", "TrainPartySettleNotify", "TrainPartyMoveScNotify",
|
||||
"TrainPartyBuildStartStepCsReq", "TrainPartyBuildDiyScRsp", "TrainPartyBuildDiyCsReq",
|
||||
"TrainPartyBuildSelfDisplayAreaDiyScNotify", "TrainPartyUseCardScRsp", "DBGHCHPONOH",
|
||||
"TrainPartySetBadgeAutoFillScRsp", "TrainPartyGamePlayStartCsReq", "TrainPartyAddBuildDynamicBuffCsReq",
|
||||
"TrainPartySyncUpdateScNotify", "TrainPartyBuildRoomScNotify", "TrainPartyGamePlaySettleNotify",
|
||||
"TrainPartyBuildSelfDisplayAreaDiyCsReq", "SwitchHandResetGameCsReq", "SwitchHandFinishCsReq",
|
||||
"GetSwitchHandUpdateScRsp", "GetSwitchHandResetHandPosScRsp", "GetSwitchHandResetHandPosCsReq",
|
||||
"SwitchHandStartScRsp", "SwitchHandCoinUpdateScRsp", "SwitchHandDataCsReq", "SwitchHandCoinUpdateCsReq",
|
||||
"SwitchHandFinishScRsp", "GetSwitchHandDataScRsp", "SwitchHandStartCsReq", "GetSwitchHandResetGameScRsp",
|
||||
"SwitchHandUpdateCsReq", "UnlockPamSkinScNotify", "SelectPamSkinScRsp", "GetPamSkinDataCsReq",
|
||||
"GetPamSkinDataScRsp", "SelectPamSkinCsReq", "TarotBookMultiOpenPackAndUnlockStoryScRsp",
|
||||
"TarotBookUnlockInteractionScRsp", "TarotBookUnlockStoryCsReq", "TarotBookFinishStoryScRsp",
|
||||
"TarotBookUnlockInteractionCsReq", "TarotBookFinishInteractionScRsp", "TarotBookOpenPackScRsp",
|
||||
"TarotBookFinishInteractionCsReq", "TarotBookGetDataCsReq", "TarotBookModifyEnergyScNotify",
|
||||
"TarotBookUnlockStoryScRsp", "TarotBookGetDataScRsp", "TarotBookOpenPackCsReq",
|
||||
"TarotBookMultiOpenPackAndUnlockStoryCsReq", "TarotBookSyncDataScNotify", "TarotBookFinishStoryCsReq",
|
||||
"ChimeraRoundWorkStartScRsp", "ChimeraDoFinalRoundCsReq", "IHBOLKMEEMG", "ChimeraStartEndlessScRsp",
|
||||
"ChimeraQuitEndlessScRsp", "ChimeraQuitEndlessCsReq", "ChimeraSetLineupScRsp",
|
||||
"ChimeraFinishEndlessRoundScRsp", "BILENDACAMP", "ChimeraFinishEndlessRoundCsReq", "ChimeraFinishRound",
|
||||
"BBHANDBNKEJ", "EIALJHKFNKG", "JHICHKBDMIC", "ChimeraDoFinalRoundScRsp", "ChimeraStartEndlessCsReq",
|
||||
"MarkChestChangedScNotify", "UpdateMarkChestScRsp", "GetMarkChestCsReq", "GetMarkChestScRsp",
|
||||
"UpdateMarkChestCsReq", "PlanetFesSetCustomKeyValueScRsp", "PlanetFesTakeQuestRewardCsReq",
|
||||
"PlanetFesBonusEventInteractCsReq", "PlanetFesClientStatusCsReq", "PlanetFesChooseAvatarEventOptionScRsp",
|
||||
"GetPlanetFesDataScRsp", "PlanetFesGetBusinessDayInfoScRsp", "PlanetFesDoGachaCsReq",
|
||||
"PlanetFesSkillLevelUpCsReq", "PlanetFesGameBingoFlipCsReq", "PlanetFesBuyLandCsReq",
|
||||
"PlanetFesChooseAvatarEventOptionCsReq", "PlanetFesDealAvatarEventOptionItemCsReq",
|
||||
"PlanetFesCollectAllIncomeCsReq", "PlanetFesDealAvatarEventOptionItemScRsp", "PlanetFesSetAvatarWorkScRsp",
|
||||
"PlanetFesTakeRegionPhaseRewardCsReq", "PlanetFesTakeQuestRewardScRsp",
|
||||
"PlanetFesBusinessDayRefreshEventCsReq", "PlanetFesSyncChangeScNotify", "PlanetFesDeliverPamCargoScRsp",
|
||||
"PlanetFesUpgradeFesLevelCsReq", "PlanetFesAvatarLevelUpScRsp", "PlanetFesUseItemScRsp",
|
||||
"PlanetFesAvatarLevelUpCsReq", "PlanetFesBuyLandScRsp", "PlanetFesBonusEventInteractScRsp",
|
||||
"PlanetFesUseItemCsReq", "PlanetFesCollectIncomeScRsp", "PlanetFesFriendRankingInfoChangeScNotify",
|
||||
"PlanetFesCollectIncomeCsReq", "PlanetFesSetAvatarWorkCsReq", "PlanetFesStartMiniGameScRsp",
|
||||
"GetPlanetFesDataCsReq", "PlanetFesTakeRegionPhaseRewardScRsp", "PlanetFesGetFriendRankingInfoListCsReq",
|
||||
"PlanetFesGetFriendRankingInfoListScRsp", "PlanetFesGetBusinessDayInfoCsReq",
|
||||
"PlanetFesDeliverPamCargoCsReq", "PlanetFesCollectAllIncomeScRsp", "PlanetFesSkillLevelUpScRsp",
|
||||
"PlanetFesBusinessDayRefreshEventScRsp", "PlanetFesStartMiniGameCsReq", "PlanetFesUpgradeFesLevelScRsp",
|
||||
"PlanetFesSetCustomKeyValueCsReq", "PlanetFesGetAvatarStatScRsp", "PlanetFesDoGachaScRsp",
|
||||
"PlanetFesClientStatusScRsp", "PlanetFesGetAvatarStatCsReq", "PlanetFesGameBingoFlipScRsp",
|
||||
"RelicSmartWearDeletePinRelicScRsp", "RelicSmartWearUpdatePinRelicCsReq",
|
||||
"RelicSmartWearUpdatePinRelicScNotify", "RelicSmartWearUpdatePlanCsReq", "RelicSmartWearDeletePlanScRsp",
|
||||
"RelicSmartWearGetPinRelicScRsp", "RelicSmartWearGetPinRelicCsReq", "RelicSmartWearAddPlanScRsp",
|
||||
"RelicSmartWearGetPlanCsReq", "RelicSmartWearUpdatePlanScRsp", "RelicSmartWearUpdatePinRelicScRsp",
|
||||
"RelicSmartWearDeletePinRelicCsReq", "RelicSmartWearGetPlanScRsp", "RelicSmartWearAddPlanCsReq",
|
||||
"RelicSmartWearDeletePlanCsReq", "MarbleShopBuyCsReq", "MarblePvpDataUpdateScNotify",
|
||||
"MarbleLevelFinishScRsp", "MarbleUpdateShownSealScRsp", "MarbleGetDataCsReq", "MarbleUpdateShownSealCsReq",
|
||||
"MarbleShopBuyScRsp", "MarbleGetDataScRsp", "MarbleLevelFinishCsReq", "MarbleUnlockSealScNotify",
|
||||
"PlanetFesEnterNextBusinessDayScRsp", "PlanetFesHandleCardPieceApplyScRsp",
|
||||
"PlanetFesGetExtraCardPieceInfoScRsp", "PlanetFesGiveCardPieceScRsp", "PlanetFesApplyCardPieceScRsp",
|
||||
"PlanetFesGetOfferedCardPieceScRsp", "PlanetFesApplyCardPieceCsReq", "PlanetFesGetFriendCardPieceCsReq",
|
||||
"PlanetFesEnterNextBusinessDayCsReq", "PlanetFesLargeBonusInteractScRsp",
|
||||
"PlanetFesHandleCardPieceApplyCsReq", "PlanetFesChangeCardPieceApplyPermissionCsReq",
|
||||
"PlanetFesGetFriendCardPieceScRsp", "PlanetFesChangeCardPieceApplyPermissionScRsp",
|
||||
"PlanetFesGetExtraCardPieceInfoCsReq", "PlanetFesGiveCardPieceCsReq", "PlanetFesLargeBonusInteractCsReq",
|
||||
"PlanetFesGetOfferedCardPieceCsReq", "GetRechargeBenefitInfoCsReq", "TakeRechargeBenefitRewardCsReq",
|
||||
"TakeRechargeGiftRewardScRsp", "GetRechargeGiftInfoCsReq", "TakeRechargeBenefitRewardScRsp",
|
||||
"GetRechargeBenefitInfoScRsp", "GetRechargeGiftInfoScRsp", "TakeRechargeGiftRewardCsReq",
|
||||
"SyncRechargeBenefitInfoScNotify", "ParkourStartLevelCsReq", "ParkourEndLevelScRsp",
|
||||
"ParkourGetRankingInfoScRsp", "ParkourGetDataCsReq", "ParkourStartLevelScRsp", "ParkourGetDataScRsp",
|
||||
"ParkourGetRankingInfoCsReq", "ParkourEndLevelCsReq", "GridFightWeeklyExtraSeasonExpScRsp",
|
||||
"GridFightSummonProjectionScRsp", "GridFightResumeGamePlayCsReq", "GridFightBuyExpScRsp", "MCIPOMDGBCA",
|
||||
"GridFightSyncKeepWinCntNotify", "KNDOLAFNJED", "GridFightEndBattleStageNotify",
|
||||
"GridFightDestroyWeeklyRewardCsReq", "CNOAPAMNDOA", "GridFightSeasonTalentResetScRsp",
|
||||
"GridFightUseHackOptionScRsp", "GridFightLockShopCsReq", "GridFightPermanentTalentEnableCsReq",
|
||||
"GridFightBuyGoodsCsReq", "IBEMBMGFDBJ", "GridFightSeasonTalentEnableCsReq", "GridFightRefreshShopCsReq",
|
||||
"GridFightQuitSettleCsReq", "GridFightHandlePendingActionCsReq", "GridFightLockShopScRsp",
|
||||
"GridFightTraitUpdateScRsp", "GridFightUpdateEliteBranchSelectCsReq", "GridFightTrackTraitCsReq",
|
||||
"OENNAOAFAMA", "GridFightGetOrbScRsp", "GridFightUseForgeScRsp", "GridFightGetFormationCsReq",
|
||||
"CCGJDDBKIDE", "LFDIMJCMNDJ", "GridFightQuitLeaveGamePlayCsReq", "FavourArchiveScRsp",
|
||||
"UpdateGameFormationScRsp", "GridFightFinishTutorialCsReq", "FavourArchiveCsReq",
|
||||
"GridFightUpdatePosScRsp", "GridFightBuyGoodsScRsp", "GridFightPermanentTalentResetScRsp",
|
||||
"GridFightEquipCraftScRsp", "GridFightOpenOrbResultScNotify", "GridFightUpdateGameRefScRsp", "HKOAOCKBBJA",
|
||||
"GridFightUpdateEquipTrackPriorityCsReq", "GridFightPermanentTalentResetCsReq",
|
||||
"GridFightSeasonTalentResetCsReq", "GridFightUseConsumableScRsp", "GridFightQuitSettleScRsp",
|
||||
"GridFightGetDataScRsp", "GridFightTakeWeeklyRewardCsReq", "GridFightUpdateEquipTrackPriorityScRsp",
|
||||
"GridFightUseHackOptionCsReq", "GridFightBackToPrepareCsReq", "GridFightFinishTutorialScRsp",
|
||||
"GridFightRecycleRoleScRsp", "GetGameFormationScRsp", "OFADBBMPDMM", "GridFightDestroyWeeklyRewardScRsp",
|
||||
"CheckTextCsReq", "GridFightGetArchiveCsReq", "GridFightEquipDressCsReq", "GridFightRefreshShopScRsp",
|
||||
"GridFightSeasonTalentEnableScRsp", "GridFightUpdateEquipTrackCsReq", "GridFightBuyExpCsReq",
|
||||
"JKGILKGILAI", "GridFightQuitLeaveGamePlayScRsp", "GridFightUseConsumableCsReq", "GEBBMDCOLCH",
|
||||
"GridFightUpdateEliteBranchSelectScRsp", "GridFightGetDataCsReq", "GridFightPermanentTalentEnableScRsp",
|
||||
"ALNFEBKHCFC", "GridFightUpdateEquipTrackScRsp", "GridFightSummonProjectionCsReq",
|
||||
"GridFightSyncUpdateResultScNotify", "GridFightStartGamePlayScRsp", "GridFightRecycleRoleCsReq",
|
||||
"GridFightEnterBattleStageScRsp", "GridFightSyncVirtualItemNotify", "GridFightEnterBattleStageCsReq",
|
||||
"GridFightGetArchiveScRsp", "GridFightSettleNotify", "GridFightGetOrbCsReq", "GridFightUpdatePosCsReq",
|
||||
"GridFightEquipCraftCsReq", "GridFightSeasonHandBookNotify", "GridFightHandlePendingActionScRsp",
|
||||
"GridFightTraitUpdateCsReq", "GridFightUseForgeCsReq", "GridFightTakeWeeklyRewardScRsp", "ENDFBBDFFDH",
|
||||
"GridFightTrackTraitScRsp", "GridFightUpdateGameRefCsReq", "GridFightResumeGamePlayScRsp",
|
||||
"GridFightEquipDressScRsp", "GridFightStartGamePlayCsReq", "TakeChallengePeakRewardScRsp",
|
||||
"GetChallengePeakDataScRsp", "SetChallengePeakBossHardModeScRsp", "ChallengePeakGroupDataUpdateScNotify",
|
||||
"GetCurChallengePeakScRsp", "ChallengePeakSettleScNotify", "SetChallengePeakMobLineupAvatarCsReq",
|
||||
"StartChallengePeakCsReq", "ReStartChallengePeakScRsp", "TakeChallengePeakRewardCsReq",
|
||||
"LeaveChallengePeakCsReq", "StartChallengePeakScRsp", "LeaveChallengePeakScRsp",
|
||||
"GetChallengePeakDataCsReq", "SetChallengePeakMobLineupAvatarScRsp", "ConfirmChallengePeakSettleScRsp",
|
||||
"SetChallengePeakBossHardModeCsReq", "ConfirmChallengePeakSettleCsReq", "ReStartChallengePeakCsReq",
|
||||
"GetCurChallengePeakCsReq", "RelicBoxRegularScNotify", "SetRelicBoxTargetCsReq", "ConfirmRelicBoxScRsp",
|
||||
"ConfirmRelicBoxCsReq", "SetRelicBoxCustomScRsp", "OpenRelicBoxScRsp", "GetRelicBoxDataCsReq",
|
||||
"OpenRelicBoxCsReq", "SetRelicBoxTargetScRsp", "GetRelicBoxDataScRsp", "SetRelicBoxCustomCsReq",
|
||||
"ReportRelicBoxActionScRsp", "ReportRelicBoxActionCsReq", "StartChallengeTierceScRsp",
|
||||
"StartNextChallengeTierceCsReq", "ChallengeTierceSyncNotify", "RestartChallengeTierceScRsp",
|
||||
"SetChallengeTierceLineupCsReq", "GetChallengeTierceDataCsReq", "StartNextChallengeTierceScRsp",
|
||||
"GetChallengeTierceControllerScRsp", "StartChallengeTierceCsReq", "ConfirmChallengeTierceStageSettleScRsp",
|
||||
"ConfirmChallengeTierceStageSettleCsReq", "RestartChallengeTierceCsReq",
|
||||
"GetChallengeTierceControllerCsReq", "GetChallengeTierceDataScRsp", "LeaveChallengeTierceScRsp",
|
||||
"LeaveChallengeTierceCsReq", "SetChallengeTierceLineupScRsp", "TakeHipplenEndingRewardCsReq",
|
||||
"HipplenCycleResultScNotify", "GetHipplenDataScRsp", "SetHipplenOutfitScRsp", "SettleHipplenWorkScRsp",
|
||||
"OpenHipplenCycleScRsp", "SettleHipplenWorkCsReq", "TakeHipplenEndingRewardScRsp",
|
||||
"GetHipplenInheritCsReq", "HipplenTraitUnlockScNotify", "HipplenAgendaResultScNotify",
|
||||
"SetHipplenAgendaCsReq", "GetHipplenInheritScRsp", "SetHipplenAgendaScRsp", "GetHipplenDataCsReq",
|
||||
"HipplenChangeScNotify", "SetHipplenOutfitCsReq", "OpenHipplenCycleCsReq", "GetFiveDimFluteDataScRsp",
|
||||
"SetChenLingGameBoyCheatCodeInputStatusScRsp", "GetChenLingGameBoyDataScRsp", "AIPNFBBDNEO",
|
||||
"GetFiveDimMiniGameDataScRsp", "ChenLingGameBoyChangeSettingsScRsp", "SetChenLingGameBoyCheatStatusScRsp",
|
||||
"JAIGKNCMOLK", "BAGCLEDHOBD", "NCGFPOKHMBL", "FinishChenLingGameBoyCsReq", "GetFiveDimFluteDataCsReq",
|
||||
"FinishFiveDimMiniGameScRsp", "ANDPLLAKCLE", "ChenLingGameBoyGetFriendRankingInfoCsReq",
|
||||
"FinishChenLingGameBoyScRsp", "ChenLingGameBoyGetFriendRankingInfoScRsp", "GetChenLingGameBoyDataCsReq",
|
||||
"EGJJHFIPCNB", "MazeCrossFloorConditionChangeScNotify", "GetFiveDimMiniGameDataCsReq",
|
||||
"FiveDimFluteDataChangeNotify", "FinishFiveDimMiniGameCsReq", "EMDKLICDPKK", "GetElationActivityDataCsReq",
|
||||
"GetElationActivityDataScRsp", "ElationActivityBattleEndScNotify", "EnterElationActivityStageScRsp",
|
||||
"EnterElationActivityStageCsReq", "GetActivityHotDataCsReq", "GetActivityHotDataScRsp",
|
||||
"GetActivityRewardCountDataCsReq", "GetActivityRewardCountDataScRsp", "GetAllActivityRewardCountDataScRsp",
|
||||
"GetAllActivityRewardCountDataCsReq", "CakeRaceGetDailyLikeCsReq", "CakeRaceLoanScRsp",
|
||||
"CakeRaceLikeFriendRankingInfoCsReq", "CakeRaceCoinScoreChangeScNotify", "CakeRaceStartPveScRsp",
|
||||
"CakeRaceGetDataCsReq", "CakeRaceGetHandbookCsReq", "CakeRaceGetFriendRankingInfoListScRsp",
|
||||
"CakeRaceLoanCsReq", "CakeRaceGetDailyLikeScRsp", "CakeRaceFinishPveScRsp", "CakeRaceFinishPveCsReq",
|
||||
"CakeRaceFinishFieldRewardScNotify", "CakeRaceStartPveCsReq", "CakeRaceGetFriendRankingInfoListCsReq",
|
||||
"CakeRaceGetHandbookScRsp", "CakeRaceLikeFriendRankingInfoScRsp", "CakeRaceUpdatePveScRsp",
|
||||
"CakeRaceUpdatePveCsReq", "CakeRaceGetDataScRsp", "LimaoNewsUpvoteInterviewScRsp",
|
||||
"LimaoNewsSetMainPageFlagScRsp", "IBPHNIBGMPH", "LimaoNewsFinishClientReplyScRsp",
|
||||
"LimaoNewsDataChangeScNotify", "LimaoNewsSubmitIssueScRsp", "LimaoNewsGetDataCsReq", "PDPKFDOJOFK",
|
||||
"LimaoNewsGetDataScRsp", "HBOOIEJHKOD", "LimaoNewsSubmitPostScRsp", "KELNHIOMCNF",
|
||||
"LimaoNewsFinishClientMessageScRsp", "LimaoNewsUpvotePostScRsp", "OLNDMBMBNDC", "ICCEJJFNNBD",
|
||||
"LimaoNewsFinishClientEventScRsp", "LimaoNewsCompleteOfficeSurveyListScRsp", "LFGKIJMBKJG", "LDCEDOGDNDC",
|
||||
"DPAJAECBCPA", "JABBBMCMKAI", "PLADCLDJGKK", "GNPIEPGLNPC", "HOALCJNIJJC", "IGIBNBCMEAG", "OFNBDEKJMON",
|
||||
"DGFHEGJKHFO", "EMOPGLBEHPA", "NFBHBDICGOG", "JGHOCEGBDCC", "LimaoNewsOfficeSurveyWithFilterScRsp",
|
||||
"LimaoNewsTrackSurveyScRsp", "ChimeraDuelShopBuyItemScRsp", "ChimeraDuelEndRoundShopStageScRsp",
|
||||
"ChimeraDuelSellChimeraScRsp", "ChimeraDuelGetDataScRsp", "ChimeraDuelShopRefreshCsReq",
|
||||
"ChimeraDuelEndGameScRsp", "ChimeraDuelSaveFriendPvpLineupCsReq", "ChimeraDuelStartGameScRsp",
|
||||
"ChimeraDuelSellChimeraCsReq", "ChimeraDuelSelectGameCsReq", "ChimeraDuelEndRoundBattleStageScRsp",
|
||||
"ChimeraDuelSelectGameScRsp", "ChimeraDuelStartGameCsReq", "ChimeraDuelSetFriendDefendLineupCsReq",
|
||||
"ChimeraDuelShopLockCsReq", "ChimeraDuelShopRefreshScRsp", "ChimeraDuelChangeLineupScRsp",
|
||||
"ChimeraDuelShopBuyItemCsReq", "ChimeraDuelFinishMasterChallengeScRsp", "ChimeraDuelShopBuyChimeraScRsp",
|
||||
"ChimeraDuelEndRoundShopStageCsReq", "ChimeraDuelFinishMasterChallengeCsReq",
|
||||
"ChimeraDuelUnlockMasterCsReq", "ChimeraDuelUnlockMasterScRsp", "ChimeraDuelGetFriendListScRsp",
|
||||
"ChimeraDuelGetDataCsReq", "ChimeraDuelSetFriendDefendLineupScRsp", "ChimeraDuelShopLockScRsp",
|
||||
"ChimeraDuelChangeLineupCsReq", "ChimeraDuelEndGameCsReq", "ChimeraDuelSaveFriendPvpLineupScRsp",
|
||||
"ChimeraDuelSyncChangeScNotify", "ChimeraDuelGetFriendListCsReq", "ChimeraDuelShopBuyChimeraCsReq",
|
||||
"ChimeraDuelEndRoundBattleStageCsReq", "DiceCombatMainPageRollDiceCsReq", "ModifyAvatarDiceRsp",
|
||||
"V2FinishPveStageCsReq", "GetSystemDataRsp", "UpgradeAvatarRsp", "AddItemRsp", "FinishPveStageScRsp",
|
||||
"V2FinishPveStageScRsp", "DDKNIMHHNPA", "BPNGPLHJHBE", "GetDiceCombatShopDataCsReq", "BuyShopGoodScRsp",
|
||||
"GetShopDataScRsp", "DiceCombatModifyAvatarDiceCsReq", "DiceCombatUpgradeAvatarCsReq",
|
||||
"DiceCombatFinishPveStageCsReq", "GetDiceCombatSystemDataCsReq", "BLBHACNKKFC", "BuyShopGoodCsReq",
|
||||
"ExpeditionBattleSetLineupScRsp", "ExpeditionBattleSettleScNotify", "ExpeditionBattleStartScRsp",
|
||||
"GetExpeditionBattleDataCsReq", "ExpeditionBattleEndScNotify", "ExpeditionBattleSetLineupCsReq",
|
||||
"ExpeditionBattleStartCsReq", "FJKEGMEPLDI", "GetExpeditionBattleDataScRsp", "TakeTaskRewardScRsp",
|
||||
"DressSpecialEquipCsReq", "IdleLiveStartNodeCsReq", "HandlePendingEquipScRsp", "NOHKADAPKHE",
|
||||
"IdleLiveGetPendingEquipCsReq", "IdleLiveCollectIncomeScRsp", "GetFriendNewAssistRewardScNotify",
|
||||
"KIDELOJLPFB", "IdleLiveFinishNodeEventScRsp", "StartNodeBattleScRsp", "IdleLiveGetPendingEquipScRsp",
|
||||
"DressSpecialEquipScRsp", "IdleLiveCollectIncomeCsReq", "TechTreeChangeNodeStateCsRsp",
|
||||
"TakeQuestEquipCsCsReq", "EndDungeonCsReq", "EMCJBMOICJN", "TechTreeChangeNodeStateCsCsReq",
|
||||
"StartDungeonScRsp", "IdleLiveGetDataCsReq", "HGDEGLENMPM", "DMEPOENPEIF", "IdleLiveFinishFinalActScRsp",
|
||||
"TakeContentCsReq", "StartDungeonCsReq", "IdleLiveFinishFinalActCsReq", "UpdateCustomInfoScRsp",
|
||||
"IdleLiveDoGachaCsReq", "IdleLiveGetStatisticScRsp", "UpdateCustomInfoCsReq",
|
||||
"IdleLiveFinishNodeBattleScRsp", "IDFMIDPCJAC", "IdleLiveFinishNodeBattleCsReq", "StartNodeBattleCsReq",
|
||||
"FinishQuestionCsReq", "ReplaceTeamCsRsp", "IdleLiveTakeAssistRewardCsReq", "ReplaceTeamCsCsReq",
|
||||
"TakeTaskRewardCsReq", "IdleLiveStartNodeScRsp", "TakeQuestEquipCsRsp", "GetFriendDungeonInfoScRsp",
|
||||
"NMEMDFOCMFO", "TakeContentScRsp", "IdleLiveGetStatisticCsReq", "NILCAHCBKAI",
|
||||
"IdleLiveFinishNodeEventCsReq", "HandlePendingEquipCsReq", "SyncChangeNtf", "EndDungeonScRsp",
|
||||
"UpgradeTeamSlotCsRsp", "DCABOALFJAP", "NMEKJABIGGH", "ChooseDeliveryScRsp", "ChooseDeliveryGetDataCsReq",
|
||||
"ChooseDeliveryGetDataScRsp", "ChooseDeliveryCsReq", "UpdateAiPamSettingsCsReq", "GetAiPamChatInfoCsReq",
|
||||
"AiPamSendMsgScRsp", "KIIKHMMDHKP", "AiPamResponseFeedbackCommentCsReq", "AiPamResponseFeedbackCsReq",
|
||||
"GetAiPamNextQuestionScRsp", "UpdateAiPamSettingsScRsp", "HGGMNLNJPHG", "RecvAiPamChatEventScNotify",
|
||||
"AiPamAskSettlementScNotify", "TriggerAiPamSpeakScRsp", "TriggerAiPamSpeakCsReq",
|
||||
"GetAiPamChatHistoryScRsp", "GetAiPamChatHistoryCsReq", "GetAiPamNextQuestionCsReq", "AiPamSendMsgCsReq",
|
||||
"AiPamMotionScNotify", "BKHLGPCDLGL", "AiPamResponseFeedbackScRsp", "LHNMDFOPMKN",
|
||||
"AiPamResponseFeedbackCommentScRsp", "GetAiPamChatInfoScRsp", "ActiveActivityDataChangeScNotify",
|
||||
"TakeActiveActivityRewardScRsp", "GetActiveActivityDataCsReq", "GetActiveActivityDataScRsp",
|
||||
"TakeActiveActivityRewardCsReq", "ChenLingRefreshCardScRsp", "ChenLingUseHandCardScRsp",
|
||||
"ChenLingBuyShopItemCsReq", "ChenLingGetDataScRsp", "ChenLingRefreshShopCsReq",
|
||||
"ChenLingFinishPrepareScRsp", "ChenLingActivatedPrivilegeScRsp", "ChenLingClientStatusChangeCsNotify",
|
||||
"ChenLingUnlockGridScRsp", "ChenLingSelectPolicyScRsp", "ChenLingBuyShopItemScRsp",
|
||||
"ChenLingSellHandCardScRsp", "ChenLingActivatedPrivilegeCsReq", "ChenLingUnlockGridCsReq",
|
||||
"ChenLingFinishShopCsReq", "ChenLingRefreshPolicyCsReq", "ChenLingRefreshShopScRsp",
|
||||
"ChenLingStartStageCsReq", "ChenLingSelectPolicyCsReq", "ChenLingRefreshCardCsReq",
|
||||
"ChenLingMoveEntityScRsp", "ChenLingFinishBattleScRsp", "ChenLingUseHandCardCsReq",
|
||||
"ChenLingMoveEntityCsReq", "ChenLingQuitStageCsReq", "ChenLingStartStageScRsp", "ChenLingQuitStageScRsp",
|
||||
"ChenLingHandlePendingActionsScRsp", "ChenLingGetDataCsReq", "ChenLingFinishShopScRsp",
|
||||
"ChenLingRefreshPolicyScRsp", "ChenLingSelectCardScRsp", "ChenLingFinishPrepareCsReq",
|
||||
"ChenLingSyncChangeScNotify", "ChenLingSelectCardCsReq", "ChenLingHandlePendingActionsCsReq",
|
||||
"ChenLingFinishBattleCsReq", "ChenLingSellHandCardCsReq", "ChenLingClientStartBattleCsNotify",
|
||||
"SocialPlayPreCheckEnterRoomScRsp", "SocialPlayGetDataScRsp", "SocialPlayGetDataCsReq",
|
||||
"SocialPlayGetOwnerRoomDataScRsp", "SocialPlayGetOwnerRoomDataCsReq", "SocialPlayRoomPlayerMoveScNotify",
|
||||
"DJIHNCHDFED", "SocialPlayQueryRecentVisitorsCsReq", "JKILAALMFIB", "SocialPlayQueryRecentVisitorsScRsp",
|
||||
"SocialPlayTalkScRsp", "SocialPlaySettingInfoScNotify", "SocialPlayGetOnlineRoomDataScRsp",
|
||||
"SocialPlayGameplayOperationScNotify", "SocialPlayGetOnlineRoomDataCsReq",
|
||||
"SocialPlayPreCheckEnterRoomCsReq", "BODEMOLDHAA", "SocialPlayTalkCsReq", "JGAMELLGLGE",
|
||||
"PixAirRefreshShopCsReq", "IIFHCHDPKIA", "PixAirGetDataScRsp", "GNCJNHIOKDL", "PixAirSellEquipCsReq",
|
||||
"PixAirReenterStageCsReq", "PixAirChangePosCsReq", "PixAirQuitStageScRsp", "PixAirRefreshShopScRsp",
|
||||
"PixAirUnlockPlaneCsReq", "PixAirSyncChangeScNotify", "EKJMIJBIIHO", "PixAirBuyGoodsScRsp",
|
||||
"PixAirBuyGoodsCsReq", "PixAirReenterStageScRsp", "PixAirStartStageScRsp", "PixAirStartStageCsReq",
|
||||
"PixAirQuitStageCsReq", "PixAirGetDataCsReq", "GBMAHPJPGLM", "KNHFDINIJOL", "PixAirSwapEquipScRsp",
|
||||
"PixAirUnlockPlaneScRsp", "PixAirSellEquipScRsp", "HENHAIFODIG", "ILFJIIDEEBI", "PAGLMEAGPPC",
|
||||
"ANKFJAABBFK", "HBOCFEEEEII", "IMFBPGDPEOI", "FMDGFHPOAIO", "HMMHDEKAHEA", "RelicPresetDeletePlanCsReq",
|
||||
"RelicPresetUpdatePlanScRsp", "RelicPresetAddPlanScRsp", "EJILGJGLKIO", "RelicPresetGetPlanCsReq",
|
||||
"EGGOCLKIAME", "RelicPresetDeletePlanScRsp", "RelicPresetGetPlanScRsp", "RelicPresetAddPlanCsReq",
|
||||
"RelicPresetUpdatePlanCsReq", "FateRinStartStoryFightScRsp", "KNLJDBNOPKD", "GHCLLIAGDEP",
|
||||
"FateRinGetDataScRsp", "AIKICHNEPKE", "StartFateRinStoryFightCsReq", "OCOEKDNDEGJ", "KDMOONKPLPI",
|
||||
"PPGFKFIAAHL", "FateRinStartHouguMapFightCsReq", "FateRinStartHouguMapFightScRsp", "GOELKOBOCDI",
|
||||
"FateRinStartChallengeFightCsReq", "PMEHLELAFJI", "EILMEGELLLJ", "DNNNCIPMPKH", "FateRinGetDataCsReq",
|
||||
"FNHBHPDJACF", "FateRinStartChallengeFightScRsp", "ChenLingFesLevelEndScRsp", "GetChenLingFesDataScRsp",
|
||||
"ChenLingFesLevelResumeScRsp", "ChenLingFesBusinessDayEndScRsp", "ChenLingFesLevelResumeCsReq",
|
||||
"ChenLingFesLevelStartCsReq", "ChenLingFesLevelEndCsReq", "ChenLingFesLevelLeaveCsReq",
|
||||
"ChenLingFesLevelStartScRsp", "ChenLingFesLevelLeaveScRsp", "GetChenLingFesDataCsReq",
|
||||
"ChenLingFesRaidKickedNotify", "ChenLingFesBusinessDayEndCsReq", "CLCOAJDPMJN", "ICLFACJDJHB",
|
||||
"TeamTowersGetDataCsReq", "KKPHDBMNNHB", "TeamTowersGetDataScRsp", "B51RacingSetDriverCsReq",
|
||||
"B51RacingReplacePartScRsp", "B51RacingSelectDevelopActionCsReq", "B51RacingCarLiveryCsReq",
|
||||
"B51RacingStartMatchScRsp", "B51RacingSelectDevelopPartScRsp", "B51RacingSelectDevelopPartCsReq",
|
||||
"B51RacingStartCycleScRsp", "B51RacingEndMatchScRsp", "B51RacingGetDataCsReq", "B51RacingSetDriverScRsp",
|
||||
"B51RacingEndMatchCsReq", "B51RacingCarLiveryScRsp", "B51RacingMissionChangeAgendaScNotify",
|
||||
"B51RacingGetDataScRsp", "B51RacingStartCycleCsReq", "B51RacingDriverUnlockScNotify",
|
||||
"B51RacingSelectDevelopActionScRsp", "B51RacingStartMatchCsReq", "JCIPKHCJLBM", "NPMOBJOCIAF",
|
||||
"JPPCNOGIMFD", "FMOEINGLFGG", "PMBMFHMKBNC", "AOBDHCFGGBH", "HPJONNDENCB", "ADDMKOIFOEF", "FKPKLHICPFO",
|
||||
"AFKFJCPELEM", "PPFAMGCOOFP", "JNDBILOGFAF", "FDCJIIKHHHB", "ELNAIJEFGMK", "HMLFGDFLIBP", "ABGMGHNOONM",
|
||||
"FIJJHJADLLM", "MGFJDLIBABO", "StartFinalityBattleLevelScRsp", "StartFinalityBattleLevelCsReq",
|
||||
"BlindBoxOpenBoxScRsp", "BlindBoxResetPoolPositionScRsp", "BlindBoxGetDataCsReq", "BlindBoxOpenBoxCsReq",
|
||||
"BlindBoxResetPoolPositionCsReq", "BlindBoxGetDataScRsp", "CHNMJPLALEC", "LPJFNJAJIGM", "CPCHHBHCGOO",
|
||||
"OAOONIMHKLA", "FAKNKCAOHON", "ILBLJAFMBBK", "MENCBJMOEML", "GNBOBLNFEHI", "DCNIDBKPPCK", "PPOPABLIHNI",
|
||||
"CDLFFHOHAIH", "JDNHIMDLIHH", "BlindBoxTradeChangeScNotify", "IAKKHBENIOL", "PublishPrivateOrderCsReq",
|
||||
"MNGELOJBJGC", "IOPNJIOPAFI", "DPOGMNFCMNN", "IMDEBPDKCAJ", "PublishPrivateOrderRsp", "BMJHNDBOPLC",
|
||||
"MIECIEKKIMJ", "NLBAOLFBEGB", "HAHEOGIBNHL", "PHIMNIFMBMI", "OKCAJHKCJDE", "BACPIBIJPPH", "HLJINHODOPP",
|
||||
"CCLIGKFAHJI", "B51RacingChallengeEndMatchCsReq", "B51RacingChallengeStartMatchScRsp",
|
||||
"B51RacingChallengeUpdateCarCsReq", "B51RacingChallengeEndMatchScRsp", "B51RacingChallengeUpdateCarScRsp",
|
||||
"B51RacingChallengeStartMatchCsReq", "CJIFMPGCOOC", "JJNAGGNONHA", "TrainCakeCatchDataScNotify",
|
||||
"TrainCakeCatchSearchCsReq", "TrainCakeCatchDiyScRsp", "BPJMEGHIEIN", "CIEAALPHGFM",
|
||||
"TrainCakeCatchOpenBoxScRsp", "TrainCakeCatchDiyLikeScRsp", "TrainCakeCatchDiyLikeCsReq",
|
||||
"TrainCakeCatchSearchScRsp", "GBHIPKDIMMH", "TrainCakeCatchOpenBoxCsReq", "TrainPetDiyScRsp",
|
||||
"TrainPetDiyCsReq", "TrainCakeCatchDiyCsReq", "PetParkDiyScRsp", "PetParkDiyCsReq",
|
||||
"PetMarbleSyncDataScNotify", "PetMarbleGetCycleTrialPetsCsReq", "LMOILOPJKCB", "PetMarbleGetDataCsReq",
|
||||
"PetMarbleGetCycleTrialPetsScRsp", "ODHFCEALPFN", "PetMarbleGetDataScRsp", "MNLLCNFNKKP",
|
||||
"PetMarbleLevelBeginScRsp", "VoracityInvasionGetDataCsReq", "GetVoracityInvasionDataScRsp",
|
||||
"VoracityInvasionChangeScNotify", "BPMFHIHNEHH", "JLMKPMCDOKC", "MLNNHJKKMNC", "OMEAEOLPGGM",
|
||||
"AFAIGBNOCJB", "FightLeaveScNotify", "FightGeneralScNotify", "FightHeartBeatScRsp", "FightEnterScRsp",
|
||||
"KIJEFPFAIJC", "MCKLINCMDKK", "FightSessionStopScNotify", "OEAMNAAJJHI", "FightGeneralScRsp",
|
||||
"FightMatch3SoloPreTurnEndScRsp", "FightMatch3StartCountDownScNotify", "FightMatch3RoyaleDataUpdateNotify",
|
||||
"FightMatch3UseItemScRsp", "FightMatch3SoloDataUpdateNotify", "FightMatch3SoloAnimEndScRsp",
|
||||
"FightMatch3ChatScRsp", "OKIPFIENKIF", "FightMatch3SoloSwapNotify", "BGEJHKFILME", "EEFDJLPDCFO",
|
||||
"FightMatch3DataScRsp", "PLHPJCKDEDI", "FightMatch3SoloUseItemNotify", "FightMatch3ForceUpdateNotify",
|
||||
"FightMatch3ChatScNotify", "FightMatch3OpponentDataScNotify", "FightMatch3SwapScRsp",
|
||||
"FightMatch3TurnEndScNotify", "PKBDAKMLDMC", "FightMatch3SoloDataScRsp", "FHAPLEMDKGN",
|
||||
"FightMatch3TurnStartScNotify", "CDPEFGCIGFB", "FightMatch3RoyaleRankUpdateNotify", "BEOGFNDKCIL",
|
||||
"FightMatch3RoyaleDataScRsp",
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,163 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace CapySR.Protocol;
|
||||
|
||||
// magic | cmdid u16 | headLen u16 | bodyLen u32 | head | body | magic. big-endian, no encryption.
|
||||
public readonly struct NetPacket
|
||||
{
|
||||
public const uint HeadMagic = 0x9D74C714;
|
||||
public const uint TailMagic = 0xD7A152C8;
|
||||
public const int Overhead = 16;
|
||||
|
||||
public NetPacket(ushort cmdId, byte[] body, byte[]? head = null)
|
||||
{
|
||||
CmdId = cmdId;
|
||||
Body = body;
|
||||
Head = head ?? [];
|
||||
}
|
||||
|
||||
public ushort CmdId { get; }
|
||||
|
||||
public byte[] Head { get; }
|
||||
|
||||
public byte[] Body { get; }
|
||||
|
||||
public string Name => ProtocolRegistry.GetName(CmdId);
|
||||
|
||||
public int Length => Overhead + Head.Length + Body.Length;
|
||||
|
||||
public static NetPacket Create<T>(T message) where T : IMessage<T> =>
|
||||
new(ProtocolRegistry.GetCmdId<T>(), message.ToByteArray());
|
||||
|
||||
public static NetPacket Create(IMessage message)
|
||||
{
|
||||
var cmdId = ProtocolRegistry.GetCmdId(message)
|
||||
?? throw new InvalidOperationException($"{message.GetType().Name} is not a top-level packet type.");
|
||||
|
||||
return new NetPacket(cmdId, message.ToByteArray());
|
||||
}
|
||||
|
||||
public static NetPacket Empty(ushort cmdId) => new(cmdId, []);
|
||||
|
||||
public void WriteTo(Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < Length)
|
||||
{
|
||||
throw new ArgumentException($"need {Length} bytes, got {destination.Length}", nameof(destination));
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination, HeadMagic);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination[4..], CmdId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(destination[6..], (ushort)Head.Length);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[8..], (uint)Body.Length);
|
||||
|
||||
var offset = 12;
|
||||
Head.CopyTo(destination[offset..]);
|
||||
offset += Head.Length;
|
||||
Body.CopyTo(destination[offset..]);
|
||||
offset += Body.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[offset..], TailMagic);
|
||||
}
|
||||
|
||||
public byte[] ToArray()
|
||||
{
|
||||
var buffer = new byte[Length];
|
||||
WriteTo(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// false rather than throw: a bad datagram should drop the session, not the process
|
||||
public static bool TryRead(ReadOnlySpan<byte> source, out NetPacket packet, out int consumed)
|
||||
{
|
||||
packet = default;
|
||||
consumed = 0;
|
||||
|
||||
if (source.Length < Overhead || BinaryPrimitives.ReadUInt32BigEndian(source) != HeadMagic)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cmdId = BinaryPrimitives.ReadUInt16BigEndian(source[4..]);
|
||||
var headLength = BinaryPrimitives.ReadUInt16BigEndian(source[6..]);
|
||||
var bodyLength = BinaryPrimitives.ReadUInt32BigEndian(source[8..]);
|
||||
|
||||
if (bodyLength > int.MaxValue - Overhead - headLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var total = Overhead + headLength + (int)bodyLength;
|
||||
if (source.Length < total || BinaryPrimitives.ReadUInt32BigEndian(source[(total - 4)..]) != TailMagic)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
packet = new NetPacket(
|
||||
cmdId,
|
||||
source.Slice(12 + headLength, (int)bodyLength).ToArray(),
|
||||
source.Slice(12, headLength).ToArray());
|
||||
|
||||
consumed = total;
|
||||
return true;
|
||||
}
|
||||
|
||||
public IMessage? ParseBody() => ProtocolRegistry.Parse(CmdId, Body);
|
||||
|
||||
public T ParseBody<T>() where T : IMessage<T>, new() => new MessageParser<T>(() => new T()).ParseFrom(Body);
|
||||
}
|
||||
|
||||
// 20-byte handshake datagrams, told apart from kcp payloads by length alone
|
||||
public readonly record struct NetOperation(uint Head, uint Param1, uint Param2, uint Data, uint Tail)
|
||||
{
|
||||
public const int Size = 20;
|
||||
|
||||
public const uint ConnectHead = 0xFF;
|
||||
public const uint ConnectTail = 0xFFFFFFFF;
|
||||
public const uint DisconnectHead = 0x194;
|
||||
public const uint DisconnectTail = 0x19419494;
|
||||
public const uint AcceptHead = 0x145;
|
||||
public const uint AcceptTail = 0x14514545;
|
||||
|
||||
public bool IsConnect => Head == ConnectHead && Tail == ConnectTail;
|
||||
|
||||
public bool IsDisconnect => Head == DisconnectHead && Tail == DisconnectTail;
|
||||
|
||||
public static NetOperation Accept(uint conversation, uint token, uint echo) =>
|
||||
new(AcceptHead, conversation, token, echo, AcceptTail);
|
||||
|
||||
public static bool TryRead(ReadOnlySpan<byte> source, out NetOperation operation)
|
||||
{
|
||||
if (source.Length < Size)
|
||||
{
|
||||
operation = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
operation = new NetOperation(
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[4..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[8..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[12..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(source[16..]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> destination)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination, Head);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[4..], Param1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[8..], Param2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[12..], Data);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(destination[16..], Tail);
|
||||
}
|
||||
|
||||
public byte[] ToArray()
|
||||
{
|
||||
var buffer = new byte[Size];
|
||||
WriteTo(buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,143 +0,0 @@
|
||||
using System.Collections.Frozen;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.Reflection;
|
||||
|
||||
namespace CapySR.Protocol;
|
||||
|
||||
// cmdid <-> message type. the generator emits only names+ids; descriptors are
|
||||
// resolved here so a renamed message fails at startup instead of silently vanishing.
|
||||
public static class ProtocolRegistry
|
||||
{
|
||||
private static readonly FrozenDictionary<ushort, MessageDescriptor> DescriptorsById;
|
||||
private static readonly FrozenDictionary<string, ushort> IdsByTypeName;
|
||||
private static readonly FrozenDictionary<ushort, string> NamesById;
|
||||
private static readonly FrozenDictionary<string, ushort> ResponseIdsByName;
|
||||
|
||||
static ProtocolRegistry()
|
||||
{
|
||||
var file = StarRailReflection.Descriptor;
|
||||
|
||||
var byId = new Dictionary<ushort, MessageDescriptor>(CmdIdTable.Names.Length);
|
||||
var byName = new Dictionary<string, ushort>(CmdIdTable.Names.Length, StringComparer.Ordinal);
|
||||
var names = new Dictionary<ushort, string>(CmdIdTable.Names.Length);
|
||||
|
||||
var ids = CmdIdTable.Ids;
|
||||
var unresolved = new List<string>();
|
||||
|
||||
for (var i = 0; i < CmdIdTable.Names.Length; i++)
|
||||
{
|
||||
var id = ids[i];
|
||||
var name = CmdIdTable.Names[i];
|
||||
names[id] = name;
|
||||
|
||||
// no package in the cleaned proto, so full name == simple name
|
||||
var descriptor = file.FindTypeByName<MessageDescriptor>(name);
|
||||
if (descriptor is null)
|
||||
{
|
||||
unresolved.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId[id] = descriptor;
|
||||
byName[descriptor.ClrType.FullName!] = id;
|
||||
}
|
||||
|
||||
if (unresolved.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{unresolved.Count} cmdid(s) reference missing messages (rerun CapySR.ProtoGen): " +
|
||||
string.Join(", ", unresolved.Take(10)));
|
||||
}
|
||||
|
||||
var responses = new Dictionary<string, ushort>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < CmdIdTable.Names.Length; i++)
|
||||
{
|
||||
if (CmdIdTable.Names[i].EndsWith("ScRsp", StringComparison.Ordinal))
|
||||
{
|
||||
responses.TryAdd(CmdIdTable.Names[i], CmdIdTable.Ids[i]);
|
||||
}
|
||||
}
|
||||
|
||||
ResponseIdsByName = responses.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
DescriptorsById = byId.ToFrozenDictionary();
|
||||
IdsByTypeName = byName.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
NamesById = names.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
public static int Count => DescriptorsById.Count;
|
||||
|
||||
public static void EnsureInitialized() => _ = DescriptorsById.Count;
|
||||
|
||||
public static string GetName(ushort cmdId) =>
|
||||
NamesById.TryGetValue(cmdId, out var name) ? name : $"Unknown({cmdId})";
|
||||
|
||||
public static string GetName(CmdId cmdId) => GetName((ushort)cmdId);
|
||||
|
||||
public static bool TryGetDescriptor(ushort cmdId, out MessageDescriptor descriptor) =>
|
||||
DescriptorsById.TryGetValue(cmdId, out descriptor!);
|
||||
|
||||
public static ushort? GetCmdId(IMessage message) =>
|
||||
IdsByTypeName.TryGetValue(message.GetType().FullName!, out var id) ? id : null;
|
||||
|
||||
public static ushort GetCmdId<T>() where T : IMessage<T> => CmdIdOf<T>.Value;
|
||||
|
||||
// GetBagCsReq -> GetBagScRsp. the dump is not perfectly regular, so a few variants are
|
||||
// tried too: UpdateServerPrefsCsReq answers as UpdateServerPrefsDataScRsp, and some
|
||||
// XxxGetDataCsReq pair with GetXxxDataScRsp.
|
||||
public static bool TryGetResponseFor(ushort requestCmdId, out ushort responseCmdId)
|
||||
{
|
||||
responseCmdId = 0;
|
||||
|
||||
if (!NamesById.TryGetValue(requestCmdId, out var name) ||
|
||||
!name.EndsWith("CsReq", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stem = name[..^5];
|
||||
|
||||
foreach (var candidate in ResponseCandidates(stem))
|
||||
{
|
||||
if (ResponseIdsByName.TryGetValue(candidate, out var id))
|
||||
{
|
||||
responseCmdId = id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResponseCandidates(string stem)
|
||||
{
|
||||
yield return stem + "ScRsp";
|
||||
yield return stem + "DataScRsp";
|
||||
|
||||
if (stem.EndsWith("Data", StringComparison.Ordinal))
|
||||
{
|
||||
yield return stem[..^4] + "ScRsp";
|
||||
}
|
||||
|
||||
// XxxGetData -> GetXxxData
|
||||
if (stem.EndsWith("GetData", StringComparison.Ordinal))
|
||||
{
|
||||
yield return "Get" + stem[..^7] + "DataScRsp";
|
||||
}
|
||||
|
||||
if (stem.StartsWith("Get", StringComparison.Ordinal))
|
||||
{
|
||||
yield return stem[3..] + "GetScRsp";
|
||||
}
|
||||
}
|
||||
|
||||
public static IMessage? Parse(ushort cmdId, ReadOnlySpan<byte> body) =>
|
||||
DescriptorsById.TryGetValue(cmdId, out var descriptor) ? descriptor.Parser.ParseFrom(body) : null;
|
||||
|
||||
private static class CmdIdOf<T> where T : IMessage<T>
|
||||
{
|
||||
public static readonly ushort Value = IdsByTypeName.TryGetValue(typeof(T).FullName!, out var id)
|
||||
? id
|
||||
: throw new InvalidOperationException($"{typeof(T).Name} is not a top-level packet type.");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
|
||||
<ProjectReference Include="..\CapySR.Protocol\CapySR.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,217 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CapySR.Common;
|
||||
using CapySR.Protocol;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace CapySR.SdkServer;
|
||||
|
||||
public sealed class VersionHotfix
|
||||
{
|
||||
[JsonPropertyName("asset_bundle_url")]
|
||||
public string AssetBundleUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ex_resource_url")]
|
||||
public string ExResourceUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("lua_url")]
|
||||
public string LuaUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("ifix_url")]
|
||||
public string IfixUrl { get; set; } = string.Empty;
|
||||
|
||||
// an empty ex_resource_url hangs the client at ~99% before it ever reaches kcp
|
||||
public bool IsUsable => AssetBundleUrl.Length > 0 && ExResourceUrl.Length > 0;
|
||||
}
|
||||
|
||||
public sealed class HotfixCache
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly HotfixConfig _config;
|
||||
private readonly ILogger<HotfixCache> _logger;
|
||||
private readonly HttpClient _http;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly string _path;
|
||||
|
||||
// versions already told about, so a borrowed hotfix is reported once and not per request
|
||||
private readonly HashSet<string> _borrowed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Dictionary<string, VersionHotfix> _versions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public HotfixCache(HotfixConfig config, ILogger<HotfixCache> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_path = Path.IsPathRooted(config.CacheFile)
|
||||
? config.CacheFile
|
||||
: Path.Combine(RepoPaths.Config, config.CacheFile);
|
||||
|
||||
_http = new HttpClient(new HttpClientHandler { UseProxy = !config.BypassProxy })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("UnityPlayer/2021.3.21f1");
|
||||
|
||||
Reload();
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> KnownVersions => _versions.Keys;
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
_versions = new Dictionary<string, VersionHotfix>(StringComparer.OrdinalIgnoreCase);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_versions = JsonSerializer.Deserialize<Dictionary<string, VersionHotfix>>(
|
||||
File.ReadAllText(_path), JsonOptions)
|
||||
?? [];
|
||||
|
||||
_versions = new Dictionary<string, VersionHotfix>(_versions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
_logger.LogError("{Path} is malformed ({Message}), starting empty", _path, e.Message);
|
||||
_versions = new Dictionary<string, VersionHotfix>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<VersionHotfix?> GetAsync(string version, string dispatchSeed)
|
||||
{
|
||||
if (_versions.TryGetValue(version, out var cached) && cached.IsUsable)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (_config.AutoFetch)
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
if (_versions.TryGetValue(version, out cached) && cached.IsUsable)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var fetched = await FetchAsync(version, dispatchSeed);
|
||||
|
||||
if (fetched is not null)
|
||||
{
|
||||
_versions[version] = fetched;
|
||||
Save();
|
||||
return fetched;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
return cached is { IsUsable: true } ? cached : Nearest(version);
|
||||
}
|
||||
|
||||
// A beta dispatch stops answering once its window closes, so a client on a build newer
|
||||
// than anything cached can never be looked up. Packaged clients ship their own resources
|
||||
// and only need the urls to be present, so lend them the closest build's.
|
||||
public VersionHotfix? Nearest(string version)
|
||||
{
|
||||
var usable = _versions.Where(v => v.Value.IsUsable).Select(v => v.Key);
|
||||
|
||||
if (!GameVersion.TryFindNearest(usable, version, out var fallback))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_borrowed.Add(version))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"no hotfix for {Version} and upstream will not serve it; lending {Fallback}'s urls. " +
|
||||
"add the real ones to {Path}, or keep Hotfix:EnableDesignDataUpdate off so the " +
|
||||
"client boots on the resources it shipped with",
|
||||
version, fallback, _path);
|
||||
}
|
||||
|
||||
return _versions[fallback];
|
||||
}
|
||||
|
||||
private async Task<VersionHotfix?> FetchAsync(string version, string dispatchSeed)
|
||||
{
|
||||
var host = SelectHost(version);
|
||||
if (host is null)
|
||||
{
|
||||
_logger.LogWarning("no upstream host known for version {Version}", version);
|
||||
return null;
|
||||
}
|
||||
|
||||
var url = $"https://{host}/query_gateway?version={version}&platform_type=1&language_type=3" +
|
||||
$"&dispatch_seed={dispatchSeed}&channel_id=1&sub_channel_id=1&is_need_url=1";
|
||||
|
||||
_logger.LogInformation("fetching hotfix for {Version} from {Host}", version, host);
|
||||
|
||||
try
|
||||
{
|
||||
var body = await _http.GetStringAsync(url);
|
||||
var gateway = GateServer.Parser.ParseFrom(Convert.FromBase64String(body.Trim()));
|
||||
|
||||
var hotfix = new VersionHotfix
|
||||
{
|
||||
AssetBundleUrl = gateway.AssetBundleUrl,
|
||||
ExResourceUrl = gateway.ExResourceUrl,
|
||||
LuaUrl = gateway.LuaUrl,
|
||||
IfixUrl = gateway.IfixUrl,
|
||||
};
|
||||
|
||||
if (!hotfix.IsUsable)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"upstream returned no urls for {Version} (retcode {Retcode}, msg '{Msg}')",
|
||||
version, gateway.Retcode, gateway.StopDesc);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("got hotfix for {Version}", version);
|
||||
return hotfix;
|
||||
}
|
||||
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or FormatException
|
||||
or InvalidProtocolBufferException)
|
||||
{
|
||||
_logger.LogError("hotfix fetch for {Version} failed: {Message}", version, e.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
File.WriteAllText(_path, JsonSerializer.Serialize(_versions, JsonOptions));
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
_logger.LogError("could not write {Path}: {Message}", _path, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SelectHost(string version) => version switch
|
||||
{
|
||||
_ when version.StartsWith("OSBETA", StringComparison.Ordinal) => "beta-release01-asia.starrails.com",
|
||||
_ when version.StartsWith("OSPROD", StringComparison.Ordinal) => "prod-official-asia-dp01.starrails.com",
|
||||
_ when version.StartsWith("CNBETA", StringComparison.Ordinal) => "beta-release01-cn.bhsr.com",
|
||||
_ when version.StartsWith("CNPROD", StringComparison.Ordinal) => "prod-gf-cn-dp01.bhsr.com",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Protocol;
|
||||
using CapySR.SdkServer;
|
||||
using Google.Protobuf;
|
||||
|
||||
var config = ServerConfig.Load();
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole(o =>
|
||||
{
|
||||
o.SingleLine = true;
|
||||
o.TimestampFormat = "HH:mm:ss ";
|
||||
});
|
||||
builder.Logging.AddProvider(new FileLoggerProvider("sdk"));
|
||||
|
||||
builder.WebHost.UseUrls($"http://{config.Dispatch.Host}:{config.Dispatch.Port}");
|
||||
builder.Services.AddSingleton(config);
|
||||
builder.Services.AddSingleton(config.Hotfix);
|
||||
builder.Services.AddSingleton<HotfixCache>();
|
||||
builder.Services.AddSingleton<ResourceMirror>();
|
||||
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseCors();
|
||||
|
||||
var log = app.Logger;
|
||||
var cache = app.Services.GetRequiredService<HotfixCache>();
|
||||
log.LogInformation("cached hotfix versions: {Versions}",
|
||||
cache.KnownVersions.Count > 0 ? string.Join(", ", cache.KnownVersions) : "none");
|
||||
|
||||
app.MapMethods("/query_dispatch", ["GET", "HEAD"], () =>
|
||||
{
|
||||
var dispatch = new Dispatch
|
||||
{
|
||||
Retcode = 0,
|
||||
RegionList =
|
||||
{
|
||||
new RegionInfo
|
||||
{
|
||||
Name = config.Dispatch.RegionName,
|
||||
Title = config.Dispatch.RegionTitle,
|
||||
DisplayName = config.Dispatch.RegionTitle,
|
||||
EnvType = "9",
|
||||
DispatchUrl = config.Dispatch.QueryGatewayUrl,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return Results.Text(Convert.ToBase64String(dispatch.ToByteArray()));
|
||||
});
|
||||
|
||||
app.MapMethods("/query_gateway", ["GET", "HEAD"], async (HttpContext context, HotfixCache hotfixCache) =>
|
||||
{
|
||||
var version = context.Request.Query["version"].ToString();
|
||||
var seed = context.Request.Query["dispatch_seed"].ToString();
|
||||
|
||||
var hotfix = await hotfixCache.GetAsync(version, seed);
|
||||
|
||||
if (hotfix is null || !hotfix.IsUsable)
|
||||
{
|
||||
log.LogError(
|
||||
"no usable hotfix for '{Version}'. add it to config/versions.json or the client will hang at 99%",
|
||||
version);
|
||||
}
|
||||
|
||||
var sendUrls = config.Hotfix.SendResourceUrls;
|
||||
var mirror = sendUrls && config.Hotfix.MirrorResources && version.Length > 0;
|
||||
|
||||
string Resource(string kind, string? direct) =>
|
||||
!sendUrls ? string.Empty
|
||||
: mirror ? ResourceMirror.UrlFor(config.Dispatch, version, kind)
|
||||
: direct ?? string.Empty;
|
||||
|
||||
var gateway = new GateServer
|
||||
{
|
||||
Ip = config.Dispatch.GatewayIp,
|
||||
Port = (uint)config.Dispatch.GatewayPort,
|
||||
RegionName = config.Dispatch.RegionName,
|
||||
AssetBundleUrl = Resource("asb", hotfix?.AssetBundleUrl),
|
||||
AssetBundleUrlAndroid = Resource("asb", hotfix?.AssetBundleUrl),
|
||||
ExResourceUrl = Resource("ex", hotfix?.ExResourceUrl),
|
||||
LuaUrl = Resource("lua", hotfix?.LuaUrl),
|
||||
IfixUrl = Resource("ifix", hotfix?.IfixUrl),
|
||||
IFixPatchRevision = "0",
|
||||
ServerDescription = config.Dispatch.RegionTitle,
|
||||
LoginWhiteMsg = config.Dispatch.RegionTitle,
|
||||
|
||||
EnableDesignDataBundleVersionUpdate = config.Hotfix.EnableDesignDataUpdate,
|
||||
EnableVideoBundleVersionUpdate = config.Hotfix.EnableVideoUpdate,
|
||||
EnableSaveReplayFile = true,
|
||||
EnableUploadBattleLog = true,
|
||||
NetworkDiagnostic = true,
|
||||
EventTrackingOpen = true,
|
||||
ForbidRecharge = true,
|
||||
CloseRedeemCode = true,
|
||||
WatermarkEnable = true,
|
||||
AndroidMiddlePackageEnable = true,
|
||||
MtpSwitch = true,
|
||||
FtcSwitch = true,
|
||||
IosExam = true,
|
||||
NNFLHCGDGJM = true,
|
||||
};
|
||||
|
||||
log.LogInformation(
|
||||
"gateway for {Version}: {Ip}:{Port}, design-data update {Update}, resources {Mode}",
|
||||
version, gateway.Ip, gateway.Port,
|
||||
config.Hotfix.EnableDesignDataUpdate ? "ON" : "off",
|
||||
mirror ? "mirrored" : sendUrls ? "direct" : "off");
|
||||
|
||||
return Results.Text(Convert.ToBase64String(gateway.ToByteArray()));
|
||||
});
|
||||
|
||||
app.MapPost("/{product}/mdk/shield/api/login", () => Results.Json(SdkResponses.Login));
|
||||
app.MapPost("/{product}/mdk/shield/api/verify", () => Results.Json(SdkResponses.Login));
|
||||
app.MapPost("/{product}/combo/granter/login/v2/login", () => Results.Json(SdkResponses.Granter));
|
||||
app.MapPost("/account/risky/api/check", () => Results.Json(SdkResponses.RiskyCheck));
|
||||
app.MapPost("/account/ma-cn-passport/app/loginByPassword", () => Results.Json(SdkResponses.PassportLogin));
|
||||
app.MapPost("/account/ma-cn-session/app/verify", () => Results.Json(SdkResponses.PassportVerify));
|
||||
|
||||
app.MapGet(ResourceMirror.Route, (HttpContext context, ResourceMirror resourceMirror,
|
||||
string version, string kind, string? path) =>
|
||||
resourceMirror.HandleAsync(context, version, kind, path));
|
||||
|
||||
app.MapPost("/srtools", async (HttpContext context) =>
|
||||
{
|
||||
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
|
||||
var result = SrToolsImport.Save(body, log);
|
||||
|
||||
return Results.Json(new { message = result.Message, status = result.Status });
|
||||
});
|
||||
|
||||
app.MapFallback((HttpContext context) =>
|
||||
{
|
||||
log.LogWarning("unhandled {Method} {Path}", context.Request.Method, context.Request.Path);
|
||||
return Results.Json(new { retcode = 0, message = "OK" });
|
||||
});
|
||||
|
||||
log.LogInformation("sdkserver listening on http://{Host}:{Port}", config.Dispatch.Host, config.Dispatch.Port);
|
||||
app.Run();
|
||||
@@ -1,91 +0,0 @@
|
||||
using System.Net;
|
||||
using CapySR.Common;
|
||||
|
||||
namespace CapySR.SdkServer;
|
||||
|
||||
// Clients sometimes resolve autopatchcn.bhsr.com to a China-mainland edge that is
|
||||
// unreachable from elsewhere, so the design-data download times out and the game hangs
|
||||
// on the loading bar. We can reach the CDN, so proxy it and hand the client our own url.
|
||||
public sealed class ResourceMirror(HotfixCache cache, ILogger<ResourceMirror> logger)
|
||||
{
|
||||
public const string Route = "/mirror/{version}/{kind}/{**path}";
|
||||
|
||||
private static readonly HttpClient Upstream = new(new HttpClientHandler
|
||||
{
|
||||
UseProxy = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10),
|
||||
};
|
||||
|
||||
public static string UrlFor(DispatchConfig dispatch, string version, string kind) =>
|
||||
$"http://{dispatch.PublicHost}:{dispatch.Port}/mirror/{Uri.EscapeDataString(version)}/{kind}";
|
||||
|
||||
public async Task HandleAsync(HttpContext context, string version, string kind, string? path)
|
||||
{
|
||||
var hotfix = await cache.GetAsync(version, string.Empty);
|
||||
|
||||
var baseUrl = hotfix is null ? null : kind switch
|
||||
{
|
||||
"asb" => hotfix.AssetBundleUrl,
|
||||
"ex" => hotfix.ExResourceUrl,
|
||||
"lua" => hotfix.LuaUrl,
|
||||
"ifix" => hotfix.IfixUrl,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(baseUrl))
|
||||
{
|
||||
logger.LogWarning("mirror: no {Kind} url for {Version}", kind, version);
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
var target = $"{baseUrl.TrimEnd('/')}/{path}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
|
||||
// the client resumes partial downloads, so range headers have to survive the hop
|
||||
if (context.Request.Headers.Range.Count > 0)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Range", (string?)context.Request.Headers.Range!);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await Upstream.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted);
|
||||
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
if (header.Key is "Content-Length" or "Content-Type" or "Content-Range" or "Last-Modified")
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (response.Headers.AcceptRanges.Count > 0)
|
||||
{
|
||||
context.Response.Headers.AcceptRanges = "bytes";
|
||||
}
|
||||
|
||||
await response.Content.CopyToAsync(context.Response.Body, context.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// client gave up on the download; nothing to report
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
logger.LogError("mirror: {Target} failed: {Message}", target, e.Message);
|
||||
|
||||
if (!context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
namespace CapySR.SdkServer;
|
||||
|
||||
// the client only checks retcode and pulls the token through; none of this is verified
|
||||
public static class SdkResponses
|
||||
{
|
||||
public static readonly object Login = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
account = new
|
||||
{
|
||||
area_code = "**",
|
||||
email = "capybara@capysr.local",
|
||||
country = "US",
|
||||
is_email_verify = "1",
|
||||
token = "capysr",
|
||||
uid = "1337",
|
||||
},
|
||||
device_grant_required = false,
|
||||
reactivate_required = false,
|
||||
realperson_required = false,
|
||||
safe_mobile_required = false,
|
||||
},
|
||||
message = "OK",
|
||||
retcode = 0,
|
||||
};
|
||||
|
||||
public static readonly object Granter = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
account_type = 1,
|
||||
combo_id = "1337",
|
||||
combo_token = "9065ad8507d5a1991cb6fddacac5999b780bbd92",
|
||||
data = "{\"guest\":false}",
|
||||
heartbeat = false,
|
||||
open_id = "1337",
|
||||
},
|
||||
message = "OK",
|
||||
retcode = 0,
|
||||
};
|
||||
|
||||
public static readonly object RiskyCheck = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
id = "06611ed14c3131a676b19c0d34c0644b",
|
||||
action = "ACTION_NONE",
|
||||
geetest = (object?)null,
|
||||
},
|
||||
message = "OK",
|
||||
retcode = 0,
|
||||
};
|
||||
|
||||
public static readonly object PassportLogin = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
token = new { token = "capysr", token_type = 1 },
|
||||
user_info = new
|
||||
{
|
||||
aid = "1337",
|
||||
mid = "1337",
|
||||
is_email_verify = 1,
|
||||
area_code = "**",
|
||||
country = "US",
|
||||
is_adult = 1,
|
||||
email = "capybara@capysr.local",
|
||||
},
|
||||
},
|
||||
message = "OK",
|
||||
retcode = 0,
|
||||
};
|
||||
|
||||
public static readonly object PassportVerify = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
tokens = new[] { new { token = "capysr", token_type = 1 } },
|
||||
user_info = new
|
||||
{
|
||||
aid = "1337",
|
||||
mid = "1337",
|
||||
is_email_verify = 1,
|
||||
area_code = "**",
|
||||
country = "US",
|
||||
is_adult = 1,
|
||||
email = "capybara@capysr.local",
|
||||
},
|
||||
},
|
||||
message = "OK",
|
||||
retcode = 0,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using CapySR.Common;
|
||||
|
||||
namespace CapySR.SdkServer;
|
||||
|
||||
// srtools.neonteam.dev posts {"data": {...freesr-data...}}; we keep the inner object
|
||||
public static class SrToolsImport
|
||||
{
|
||||
public static string TargetPath => Path.Combine(RepoPaths.Data, "freesr-data.json");
|
||||
|
||||
public static (string Message, int Status) Save(string body, ILogger log)
|
||||
{
|
||||
JsonDocument document;
|
||||
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(body);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
log.LogError("srtools sent malformed json: {Message}", e.Message);
|
||||
return ($"malformed json: {e.Message}", 500);
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
if (!document.RootElement.TryGetProperty("data", out var data) ||
|
||||
data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
return ("OK", 200);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(RepoPaths.Data);
|
||||
|
||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
// write-then-move so the game server's watcher never sees a half file
|
||||
var temp = TargetPath + ".tmp";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, TargetPath, overwrite: true);
|
||||
|
||||
log.LogInformation("saved srtools data to {Path} ({Size} KiB)", TargetPath, json.Length / 1024);
|
||||
return ("OK", 200);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
log.LogError("could not write {Path}: {Message}", TargetPath, e.Message);
|
||||
return ($"failed to write freesr-data.json: {e.Message}", 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
#requires -version 5
|
||||
param(
|
||||
[switch]$NoClient, # servers only
|
||||
[switch]$NoBuild # skip the build step
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $root
|
||||
|
||||
function Say($text, $colour = 'Gray') { Write-Host $text -ForegroundColor $colour }
|
||||
|
||||
Say ''
|
||||
Say ' CapySR' Cyan
|
||||
Say ' ------' Cyan
|
||||
|
||||
# a leftover server holds the ports and the new one dies on bind
|
||||
$stale = Get-Process CapySR.SdkServer, CapySR.GameServer -ErrorAction SilentlyContinue
|
||||
if ($stale) {
|
||||
Say " stopping $($stale.Count) running server(s)..." DarkGray
|
||||
$stale | Stop-Process -Force
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
|
||||
if (-not $NoBuild) {
|
||||
Say ' building...' DarkGray
|
||||
$build = & dotnet build "$root\CapySR.slnx" -c Release --nologo -v quiet 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Say ' BUILD FAILED' Red
|
||||
$build | Select-Object -Last 25 | ForEach-Object { Write-Host " $_" }
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$root\logs" | Out-Null
|
||||
Remove-Item "$root\logs\sdk.log", "$root\logs\game.log" -ErrorAction SilentlyContinue
|
||||
|
||||
Say ' starting sdk server (dispatch, port 21000)' DarkGray
|
||||
Start-Process dotnet -ArgumentList "run --project `"$root\src\CapySR.SdkServer`" -c Release --no-build" `
|
||||
-WorkingDirectory $root -WindowStyle Minimized
|
||||
|
||||
Say ' starting game server (kcp, port 23301)' DarkGray
|
||||
Start-Process dotnet -ArgumentList "run --project `"$root\src\CapySR.GameServer`" -c Release --no-build" `
|
||||
-WorkingDirectory $root -WindowStyle Minimized
|
||||
|
||||
function Wait-ForLog($file, $needle, $seconds = 90) {
|
||||
$deadline = (Get-Date).AddSeconds($seconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $file) {
|
||||
if (Select-String -Path $file -Pattern $needle -Quiet -ErrorAction SilentlyContinue) { return $true }
|
||||
}
|
||||
Start-Sleep -Milliseconds 400
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
$sdkOk = Wait-ForLog "$root\logs\sdk.log" 'sdkserver listening'
|
||||
$gameOk = Wait-ForLog "$root\logs\game.log" 'kcp gateway listening'
|
||||
|
||||
Say ''
|
||||
if ($sdkOk) { Say ' [ok] sdk server ready' Green } else { Say ' [FAIL] sdk server - see logs\sdk.log' Red }
|
||||
if ($gameOk) { Say ' [ok] game server ready' Green } else { Say ' [FAIL] game server - see logs\game.log' Red }
|
||||
|
||||
if (-not ($sdkOk -and $gameOk)) {
|
||||
Say ''
|
||||
Say ' servers did not come up. last lines:' Yellow
|
||||
foreach ($f in @("$root\logs\sdk.log", "$root\logs\game.log")) {
|
||||
if (Test-Path $f) { Say " -- $f" DarkGray; Get-Content $f -Tail 8 | ForEach-Object { Write-Host " $_" } }
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
foreach ($line in (Select-String -Path "$root\logs\game.log" -Pattern 'game data loaded|srtools data').Line) {
|
||||
Say " $($line -replace '^\S+\s+\S+\s+\S+:\s*', '')" DarkGray
|
||||
}
|
||||
|
||||
if ($NoClient) { Say ''; Say ' servers up. -NoClient given, not launching the game.' Cyan; exit 0 }
|
||||
|
||||
# --- client -----------------------------------------------------------------
|
||||
|
||||
$config = Get-Content "$root\config\config.json" -Raw | ConvertFrom-Json
|
||||
$clientDir = $config.Client.Path
|
||||
|
||||
if (-not $clientDir -or -not (Test-Path $clientDir)) {
|
||||
Say ''
|
||||
Say ' no client configured. set Client.Path in config\config.json to the folder' Yellow
|
||||
Say ' holding StarRail.exe, then run this again.' Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
$launcher = Join-Path $clientDir 'launcher.exe'
|
||||
$dll = Join-Path $clientDir 'hkrpg.dll'
|
||||
$patch = Join-Path $root 'stuff\pearl-sr\launcher'
|
||||
|
||||
# keep the injector in step with the copy in pearl-sr
|
||||
foreach ($file in 'launcher.exe', 'hkrpg.dll') {
|
||||
$src = Join-Path $patch $file
|
||||
$dst = Join-Path $clientDir $file
|
||||
if ((Test-Path $src) -and (-not (Test-Path $dst) -or
|
||||
(Get-Item $src).LastWriteTime -gt (Get-Item $dst).LastWriteTime)) {
|
||||
Say " updating $file in the client folder" DarkGray
|
||||
Copy-Item $src $dst -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $launcher) -or -not (Test-Path $dll)) {
|
||||
Say ''
|
||||
Say " launcher.exe / hkrpg.dll missing from $clientDir" Red
|
||||
Say " copy them from $patch" Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say ' launching the client (accept the admin prompt)...' Cyan
|
||||
Start-Process -FilePath $launcher -WorkingDirectory $clientDir -Verb RunAs
|
||||
|
||||
Say ''
|
||||
Say ' logs\sdk.log and logs\game.log are live if anything goes wrong.' DarkGray
|
||||
Say ''
|
||||
@@ -1,401 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Battle;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class BattleTests(ITestOutputHelper output)
|
||||
{
|
||||
private const uint AllWaves = 0xFFFFFFFF;
|
||||
|
||||
private static GameData? Load()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
return sources.Count == 0 ? null : GameData.Load(sources);
|
||||
}
|
||||
|
||||
private static SrToolsData PlayerWith(params uint[] avatarIds)
|
||||
{
|
||||
var data = new SrToolsData();
|
||||
|
||||
foreach (var id in avatarIds)
|
||||
{
|
||||
data.Avatars[id] = new SrAvatar { AvatarId = id, Level = 80, Promotion = 6 };
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static BattleRequest Request(uint stageId, params uint[] lineup) => new()
|
||||
{
|
||||
StageId = stageId,
|
||||
Lineup = lineup,
|
||||
CycleCount = 30,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void BuildsBattleFromStageData()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var battle = builder.Build(Request(1022010, 1001, 1003), PlayerWith(1001, 1003));
|
||||
|
||||
Assert.Equal(1022010u, battle.StageId);
|
||||
Assert.Equal(2, battle.BattleAvatarList.Count);
|
||||
Assert.Equal(30u, battle.RoundsLimit);
|
||||
|
||||
// stage 1022010 is one wave of four monsters
|
||||
var wave = Assert.Single(battle.MonsterWaveList);
|
||||
Assert.Equal(1u, wave.BattleWaveId);
|
||||
Assert.Equal([8001010u, 8001020u, 8001010u, 8001020u], wave.MonsterList.Select(m => m.MonsterId));
|
||||
Assert.Equal(16u, wave.MonsterParam.Level);
|
||||
Assert.Equal((uint)battle.MonsterWaveList.Count, battle.MonsterWaveLength);
|
||||
Assert.NotEqual(0u, battle.LogicRandomSeed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryAvatarProducesAUsableBattleAvatar()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var noTree = new List<uint>();
|
||||
var noBuff = new List<uint>();
|
||||
|
||||
// playable ranges only - 6xxx/7xxx are npc and trial models with no trace tree
|
||||
var playable = data.Avatars.Keys
|
||||
.Where(id => id is (> 1000 and < 2000) or (>= 8001 and <= 8010))
|
||||
.OrderBy(id => id)
|
||||
.ToList();
|
||||
|
||||
foreach (var avatarId in playable)
|
||||
{
|
||||
var battle = builder.Build(Request(1022010, avatarId), PlayerWith(avatarId));
|
||||
var avatar = Assert.Single(battle.BattleAvatarList);
|
||||
|
||||
if (avatar.SkilltreeList.Count == 0)
|
||||
{
|
||||
noTree.Add(avatarId);
|
||||
}
|
||||
|
||||
if (!battle.BuffList.Any(b => b.OwnerIndex == 0 && b.Id != 0))
|
||||
{
|
||||
noBuff.Add(avatarId);
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteLine($"checked {playable.Count} playable avatars");
|
||||
Assert.True(playable.Count >= 90, $"only found {playable.Count} playable avatars");
|
||||
Assert.True(noTree.Count == 0, $"no skill tree: {string.Join(", ", noTree)}");
|
||||
Assert.True(noBuff.Count == 0, $"no technique buff: {string.Join(", ", noBuff)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewBetaCharacterBuildsCompletely()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null || !data.Avatars.ContainsKey(1503))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var battle = builder.Build(Request(1022010, 1503), PlayerWith(1503));
|
||||
|
||||
var pearl = Assert.Single(battle.BattleAvatarList);
|
||||
Assert.Equal(1503u, pearl.Id);
|
||||
Assert.True(pearl.SkilltreeList.Count >= 10, $"only {pearl.SkilltreeList.Count} trace points");
|
||||
|
||||
// 1503 is absent from AvatarDefaultMazeBuff, so it must come from the fallback table
|
||||
Assert.Contains(battle.BuffList, b => b.Id == 150301);
|
||||
Assert.Contains(battle.BuffList, b => b.Id == 1000121);
|
||||
Assert.DoesNotContain(battle.BuffList, b => b.Id == 150301 + 0 && b.WaveFlag != AllWaves);
|
||||
}
|
||||
|
||||
// guards against regressing away from what the 4.6 reference server sends
|
||||
[Theory]
|
||||
[InlineData(1001, 100101u)]
|
||||
[InlineData(1208, 120802u)]
|
||||
[InlineData(1304, 130403u)]
|
||||
[InlineData(1308, 130803u)]
|
||||
[InlineData(1310, 131001u)]
|
||||
[InlineData(1412, 141201u)]
|
||||
[InlineData(1510, 151002u)]
|
||||
public void TechniqueBuffsMatchTheReference(uint avatarId, uint expectedBuffId)
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buffs = TechniqueBuffs.For(data, avatarId).Select(b => b.Id).ToList();
|
||||
Assert.Contains(expectedBuffId, buffs);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1208, 120801u)]
|
||||
[InlineData(1310, 1000112u)]
|
||||
[InlineData(1412, 1000121u)]
|
||||
[InlineData(1224, 122401u)]
|
||||
public void ExtraTechniqueBuffsAreKept(uint avatarId, uint extraBuffId)
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Contains(extraBuffId, TechniqueBuffs.For(data, avatarId).Select(b => b.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FemaleTrailblazerPathsReuseMaleBuffs()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (female, male) in new[] { (8004u, 8003u), (8006u, 8005u), (8008u, 8007u), (8010u, 8009u) })
|
||||
{
|
||||
var f = TechniqueBuffs.For(data, female).Select(b => b.Id).ToList();
|
||||
var m = TechniqueBuffs.For(data, male).Select(b => b.Id).ToList();
|
||||
Assert.Equal(m, f);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Physical", 1000111u)]
|
||||
[InlineData("Fire", 1000112u)]
|
||||
[InlineData("Ice", 1000113u)]
|
||||
[InlineData("Thunder", 1000114u)]
|
||||
[InlineData("Wind", 1000115u)]
|
||||
[InlineData("Quantum", 1000116u)]
|
||||
[InlineData("Imaginary", 1000117u)]
|
||||
public void AttackerBuffFollowsElement(string element, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, TechniqueBuffs.AttackerBuff(element));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaderElementDrivesTheAttackerBuff()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
|
||||
// 1001 March 7th is Ice
|
||||
var battle = builder.Build(Request(1022010, 1001), PlayerWith(1001));
|
||||
Assert.Contains(battle.BuffList, b => b.Id == 1000113 && b.DynamicValues["SkillIndex"] == 1f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalBuffsOnlyApplyWhenTheAvatarIsOwned()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null || data.GlobalMazeBuffs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var (globalAvatar, globalBuff) = data.GlobalMazeBuffs[0];
|
||||
|
||||
var without = builder.Build(Request(1022010, 1001), PlayerWith(1001));
|
||||
Assert.DoesNotContain(without.BuffList, b => b.Id == globalBuff);
|
||||
|
||||
var with = builder.Build(Request(1022010, 1001), PlayerWith(1001, globalAvatar));
|
||||
Assert.Contains(with.BuffList, b => b.Id == globalBuff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalBuffsAreTheOnesTheReferenceHardcodes()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = data.GlobalMazeBuffs.Select(g => g.BuffId).OrderBy(i => i).ToList();
|
||||
Assert.Equal([140703u, 150602u], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PureFictionGetsScoreTargets()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var request = Request(30301001, 1001);
|
||||
request.Kind = BattleKind.PureFiction;
|
||||
|
||||
var battle = builder.Build(request, PlayerWith(1001));
|
||||
|
||||
Assert.Equal(5, battle.BattleTargetInfo.Count);
|
||||
Assert.Contains(battle.BattleTargetInfo[1].BattleTargetList_, t => t.Id is 10002 or 10003);
|
||||
Assert.Equal([2001u, 2002u], battle.BattleTargetInfo[5].BattleTargetList_.Select(t => t.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApocalypticShadowGetsItsGauge()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
var request = Request(1022010, 1001);
|
||||
request.Kind = BattleKind.ApocalypticShadow;
|
||||
|
||||
var battle = builder.Build(request, PlayerWith(1001));
|
||||
Assert.Equal(90005u, Assert.Single(battle.BattleTargetInfo[1].BattleTargetList_).Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvadedStagesCarryTheInvasionBuff()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null || data.StageInvasions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var invasion = data.StageInvasions.Values.First();
|
||||
var battle = new BattleBuilder(data).Build(Request(invasion.StageID, 1001), PlayerWith(1001));
|
||||
|
||||
Assert.Contains(battle.BuffList, b => b.Id == 3034000 + invasion.InvasionID);
|
||||
|
||||
var plain = new BattleBuilder(data).Build(Request(1022010, 1001), PlayerWith(1001));
|
||||
Assert.DoesNotContain(plain.BuffList, b => b.Id is >= 3034000 and < 3035000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnhancedAvatarsCarryTheirEnhancedId()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new BattleBuilder(data);
|
||||
|
||||
// 1310 is one of the ten in AvatarConfigEnhanced; the kit is a toggle, off by default
|
||||
var untoggled = builder.Build(Request(1022010, 1310), PlayerWith(1310));
|
||||
Assert.Equal(0u, Assert.Single(untoggled.BattleAvatarList).EnhancedId);
|
||||
|
||||
var toggled = Request(1022010, 1310);
|
||||
toggled.IsEnhanced = _ => true;
|
||||
Assert.NotEqual(0u, Assert.Single(builder.Build(toggled, PlayerWith(1310)).BattleAvatarList).EnhancedId);
|
||||
|
||||
// srtools can switch it on too
|
||||
var srtools = PlayerWith(1310);
|
||||
srtools.Avatars[1310].EnhancedId = 1;
|
||||
Assert.Equal(1u, Assert.Single(builder.Build(Request(1022010, 1310), srtools).BattleAvatarList).EnhancedId);
|
||||
|
||||
// an avatar without an enhanced kit never gets one, toggle or not
|
||||
var plain = Request(1022010, 1001);
|
||||
plain.IsEnhanced = _ => true;
|
||||
Assert.Equal(0u, Assert.Single(builder.Build(plain, PlayerWith(1001)).BattleAvatarList).EnhancedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SrToolsGearReachesTheBattle()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = PlayerWith(1001);
|
||||
player.Lightcones.Add(new SrLightcone { ItemId = 23001, EquipAvatar = 1001, Level = 80, Rank = 5, Promotion = 6, InternalUid = 1 });
|
||||
player.Relics.Add(new SrRelic
|
||||
{
|
||||
RelicId = 61011,
|
||||
EquipAvatar = 1001,
|
||||
Level = 15,
|
||||
MainAffixId = 1,
|
||||
InternalUid = 7,
|
||||
SubAffixes = [new SrSubAffix { SubAffixId = 2, Count = 5, Step = 3 }],
|
||||
});
|
||||
|
||||
var battle = new BattleBuilder(data).Build(Request(1022010, 1001), player);
|
||||
var avatar = Assert.Single(battle.BattleAvatarList);
|
||||
|
||||
var lightcone = Assert.Single(avatar.EquipmentList);
|
||||
Assert.Equal(23001u, lightcone.Id);
|
||||
Assert.Equal(5u, lightcone.Rank);
|
||||
|
||||
var relic = Assert.Single(avatar.RelicList);
|
||||
Assert.Equal(61011u, relic.Id);
|
||||
var affix = Assert.Single(relic.SubAffixList);
|
||||
Assert.Equal(5u, affix.Cnt);
|
||||
Assert.Equal(3u, affix.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomMonsterWavesOverrideTheStage()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var request = Request(1022010, 1001);
|
||||
request.MonsterWaves =
|
||||
[
|
||||
[new SrMonster { MonsterId = 3014022, Level = 95 }],
|
||||
[new SrMonster { MonsterId = 3014023, Level = 95 }, new SrMonster { MonsterId = 3014024, Level = 95 }],
|
||||
];
|
||||
|
||||
var battle = new BattleBuilder(data).Build(request, PlayerWith(1001));
|
||||
|
||||
Assert.Equal(2, battle.MonsterWaveList.Count);
|
||||
Assert.Equal(2u, battle.MonsterWaveLength);
|
||||
Assert.Equal([1u, 2u], battle.MonsterWaveList.Select(w => w.BattleWaveId));
|
||||
Assert.Equal(95u, battle.MonsterWaveList[0].MonsterParam.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuffsTargetEveryWave()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var battle = new BattleBuilder(data).Build(Request(1022010, 1001, 1003), PlayerWith(1001, 1003));
|
||||
|
||||
Assert.NotEmpty(battle.BuffList);
|
||||
Assert.All(battle.BuffList, b => Assert.Equal(AllWaves, b.WaveFlag));
|
||||
Assert.All(battle.BuffList, b => Assert.NotEqual(0u, b.Id));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\CapySR.Common\CapySR.Common.csproj" />
|
||||
<ProjectReference Include="..\..\src\CapySR.Data\CapySR.Data.csproj" />
|
||||
<ProjectReference Include="..\..\src\CapySR.GameServer\CapySR.GameServer.csproj" />
|
||||
<ProjectReference Include="..\..\src\CapySR.Kcp\CapySR.Kcp.csproj" />
|
||||
<ProjectReference Include="..\..\src\CapySR.Protocol\CapySR.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,324 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Scene;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class ChallengeTests(ITestOutputHelper output)
|
||||
{
|
||||
private static GameData? Load()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
return sources.Count == 0 ? null : GameData.Load(sources);
|
||||
}
|
||||
|
||||
private static readonly uint[] Lineup = [1001, 1003, 1004, 1005];
|
||||
|
||||
[Fact]
|
||||
public void EveryChallengeFloorResolvesAScene()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var failed = new List<uint>();
|
||||
var noStage = new List<uint>();
|
||||
|
||||
foreach (var challenge in data.Challenges.Values.OrderBy(c => c.ID))
|
||||
{
|
||||
var state = new ChallengeState();
|
||||
|
||||
if (!state.StartChallenge(data, challenge, 0, 0, Lineup))
|
||||
{
|
||||
failed.Add(challenge.ID);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.GetStage(state.StageId) is null)
|
||||
{
|
||||
noStage.Add(challenge.ID);
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteLine($"{data.Challenges.Count} floors, {failed.Count} unresolvable, {noStage.Count} without a stage");
|
||||
Assert.True(failed.Count == 0, $"no scene for: {string.Join(", ", failed.Take(12))}");
|
||||
Assert.True(noStage.Count == 0, $"no stage for: {string.Join(", ", noStage.Take(12))}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondNodeResolvesWhereDefined()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var withSecond = data.Challenges.Values.Where(c => c.HasSecondNode).ToList();
|
||||
output.WriteLine($"{withSecond.Count}/{data.Challenges.Count} floors have a second node");
|
||||
Assert.NotEmpty(withSecond);
|
||||
|
||||
foreach (var challenge in withSecond.Take(50))
|
||||
{
|
||||
var state = new ChallengeState();
|
||||
Assert.True(state.StartChallenge(data, challenge, 1, 0, Lineup), $"floor {challenge.ID} node 2");
|
||||
Assert.Equal(1, state.Node);
|
||||
Assert.NotEqual(0u, state.StageId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChallengeCarriesMazeBuffAndPlayerPick()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var challenge = data.Challenges.Values.First(c => c.MazeBuffID != 0);
|
||||
var state = new ChallengeState();
|
||||
|
||||
Assert.True(state.StartChallenge(data, challenge, 0, 900001, Lineup));
|
||||
Assert.Contains(challenge.MazeBuffID, state.Blessings);
|
||||
Assert.Contains(900001u, state.Blessings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChallengeKindsSplitByIdRange()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.All(data.Challenges.Values.Where(c => c.ID < 20000), c => Assert.Equal(ChallengeKind.MemoryOfChaos, c.Kind));
|
||||
Assert.All(data.Challenges.Values.Where(c => c.ID is > 20000 and < 30000), c => Assert.Equal(ChallengeKind.PureFiction, c.Kind));
|
||||
Assert.All(data.Challenges.Values.Where(c => c.ID > 30000), c => Assert.Equal(ChallengeKind.ApocalypticShadow, c.Kind));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryAaStageResolvesAScene()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var playable = data.ChallengePeaks.Values.Where(p => p.IsPlayable).ToList();
|
||||
Assert.NotEmpty(playable);
|
||||
|
||||
foreach (var peak in playable)
|
||||
{
|
||||
data.ChallengePeakBosses.TryGetValue(peak.ID, out var boss);
|
||||
var state = new ChallengeState();
|
||||
|
||||
Assert.True(state.StartPeak(data, peak, boss, 0, Lineup), $"AA {peak.ID}");
|
||||
Assert.NotEqual(0u, state.StageId);
|
||||
Assert.NotNull(data.GetStage(state.StageId));
|
||||
Assert.NotEmpty(state.Blessings);
|
||||
}
|
||||
|
||||
output.WriteLine($"{playable.Count} AA stages all resolve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AaHardModeSwapsTagsAndEncounter()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var boss = data.ChallengePeakBosses.Values.FirstOrDefault(b => b.HardTagList.Count > 0);
|
||||
if (boss is null || !data.ChallengePeaks.TryGetValue(boss.ID, out var peak) || !peak.IsPlayable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var easy = new ChallengeState();
|
||||
easy.StartPeak(data, peak, boss, 0, Lineup);
|
||||
|
||||
var hard = new ChallengeState { HardMode = true };
|
||||
hard.StartPeak(data, peak, boss, 0, Lineup);
|
||||
|
||||
Assert.NotEqual(easy.Blessings, hard.Blessings);
|
||||
Assert.All(boss.HardTagList, tag => Assert.Contains(tag, hard.Blessings));
|
||||
|
||||
if (boss.HardEventIDList.Count > 0)
|
||||
{
|
||||
Assert.Equal(boss.HardEventIDList[^1], hard.StageId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceneHasAvatarsAndTheEncounter()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var challenge = data.Challenges.Values.First();
|
||||
var state = new ChallengeState();
|
||||
Assert.True(state.StartChallenge(data, challenge, 0, 0, Lineup));
|
||||
|
||||
var scene = new SceneBuilder(data).BuildChallengeScene(state);
|
||||
|
||||
Assert.Equal(state.FloorId, scene.FloorId);
|
||||
Assert.Equal(state.EntryId, scene.EntryId);
|
||||
Assert.NotEqual(0u, scene.PlaneId);
|
||||
Assert.Equal(2, scene.EntityGroupList.Count);
|
||||
|
||||
var avatars = scene.EntityGroupList[0];
|
||||
Assert.Equal(Lineup.Length, avatars.EntityList.Count);
|
||||
Assert.All(avatars.EntityList, e => Assert.NotNull(e.Actor));
|
||||
Assert.Equal(avatars.EntityList[0].EntityId, scene.LeaderEntityId);
|
||||
|
||||
var monsters = scene.EntityGroupList[1];
|
||||
var monster = Assert.Single(monsters.EntityList);
|
||||
Assert.NotNull(monster.NpcMonster);
|
||||
Assert.Equal(state.MonsterId, monster.NpcMonster.MonsterId);
|
||||
Assert.Equal(state.EventId, monster.NpcMonster.EventId);
|
||||
Assert.Equal(state.GroupId, monsters.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipathAvatarsUseTheirBaseIdInScenes()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var challenge = data.Challenges.Values.First();
|
||||
var state = new ChallengeState();
|
||||
state.StartChallenge(data, challenge, 0, 0, [8010, 1224]);
|
||||
|
||||
var scene = new SceneBuilder(data).BuildChallengeScene(state);
|
||||
var actors = scene.EntityGroupList[0].EntityList.Select(e => e.Actor!.BaseAvatarId).ToList();
|
||||
|
||||
Assert.Equal([8001u, 1001u], actors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearResetsEverything()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var state = new ChallengeState();
|
||||
state.StartChallenge(data, data.Challenges.Values.First(), 0, 0, Lineup);
|
||||
Assert.True(state.Active);
|
||||
|
||||
state.Clear();
|
||||
|
||||
Assert.False(state.Active);
|
||||
Assert.Equal(0u, state.StageId);
|
||||
Assert.Empty(state.Lineup);
|
||||
Assert.Empty(state.Blessings);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorldSceneTests(ITestOutputHelper output)
|
||||
{
|
||||
private static GameData? Load()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
return sources.Count == 0 ? null : GameData.Load(sources);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrainSceneHasAvatarsAndResolves()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scene = new SceneBuilder(data).BuildWorldScene(1000001, [8002, 1001, 1003, 1004]);
|
||||
|
||||
Assert.Equal(1000001u, scene.EntryId);
|
||||
Assert.Equal(10000u, scene.PlaneId);
|
||||
Assert.Equal(10000000u, scene.FloorId);
|
||||
Assert.NotEqual(0u, scene.LeaderEntityId);
|
||||
|
||||
var avatars = scene.EntityGroupList[0];
|
||||
Assert.Equal(4, avatars.EntityList.Count);
|
||||
Assert.All(avatars.EntityList, e => Assert.NotNull(e.Actor));
|
||||
|
||||
// 8002 collapses to the 8001 base id in scenes
|
||||
Assert.Equal(8001u, avatars.EntityList[0].Actor.BaseAvatarId);
|
||||
|
||||
// the train deliberately gets no props back
|
||||
Assert.Single(scene.EntityGroupList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonTrainSceneGetsItsProps()
|
||||
{
|
||||
var data = Load();
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scene = new SceneBuilder(data).BuildWorldScene(2000101, [8002]);
|
||||
|
||||
Assert.Equal(20001u, scene.PlaneId);
|
||||
Assert.True(scene.EntityGroupList.Count > 1, "expected prop groups");
|
||||
output.WriteLine($"entry 2000101: {scene.EntityGroupList.Count} groups, " +
|
||||
$"{scene.EntityGroupList.Sum(g => g.EntityList.Count)} entities");
|
||||
}
|
||||
}
|
||||
|
||||
public class StartSceneTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public void StartSceneIsPopulatedAndAvatarsAreNotAtTheOrigin()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = GameData.Load(sources);
|
||||
var server = new GameServerConfig();
|
||||
|
||||
var scene = new SceneBuilder(data).BuildWorldScene(
|
||||
server.StartEntryId, [8002, 1001, 1003, 1004], server.StartTeleportId, server.WorldId);
|
||||
|
||||
output.WriteLine($"entry {scene.EntryId}: plane {scene.PlaneId}, floor {scene.FloorId}, " +
|
||||
$"{scene.EntityGroupList.Count} groups, " +
|
||||
$"{scene.EntityGroupList.Sum(g => g.EntityList.Count)} entities");
|
||||
|
||||
Assert.Equal(server.StartEntryId, scene.EntryId);
|
||||
Assert.NotEqual(0u, scene.PlaneId);
|
||||
Assert.NotEqual(0u, scene.FloorId);
|
||||
Assert.NotEqual(0u, scene.LeaderEntityId);
|
||||
|
||||
// the environment has to come through, not just the party
|
||||
Assert.True(scene.EntityGroupList.Count > 1, "start scene has no props or monsters");
|
||||
|
||||
var spawn = scene.EntityGroupList[0].EntityList[0].Motion.Pos;
|
||||
Assert.False(spawn.X == 0 && spawn.Y == 0 && spawn.Z == 0,
|
||||
"avatars spawned at the origin - the teleport position was not applied");
|
||||
output.WriteLine($"spawn: {spawn.X},{spawn.Y},{spawn.Z}");
|
||||
}
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// the whole pipeline through a real gateway: login, teams, overworld fights, a MOC floor
|
||||
public class FlowTests(ITestOutputHelper output)
|
||||
{
|
||||
private static GameWorld World => TestWorld.Current;
|
||||
|
||||
// the default harness plays real stages so an assertion about a fight is about the data,
|
||||
// not about whatever freesr-data.json happens to hold
|
||||
private static ServerConfig TestConfig() => new()
|
||||
{
|
||||
GameServer = { Host = "127.0.0.1", Persist = false, BattleSource = "stage" },
|
||||
};
|
||||
|
||||
private sealed class Harness : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new(TimeSpan.FromSeconds(60));
|
||||
private readonly Task _server;
|
||||
|
||||
public Harness(string battleSource = "stage")
|
||||
{
|
||||
var port = FreeUdpPort();
|
||||
var config = TestConfig();
|
||||
config.GameServer.Port = port;
|
||||
config.GameServer.BattleSource = battleSource;
|
||||
|
||||
var handlers = HandlerRegistry.Build(NullLogger.Instance);
|
||||
var gateway = new KcpGateway(config, handlers, World, NullLogger<KcpGateway>.Instance);
|
||||
_server = gateway.RunAsync(_cancellation.Token);
|
||||
Client = new TestClient(new IPEndPoint(IPAddress.Loopback, port));
|
||||
}
|
||||
|
||||
public TestClient Client { get; }
|
||||
|
||||
public async Task LoginAsync()
|
||||
{
|
||||
await Client.ConnectAsync();
|
||||
await Client.RequestAsync<PlayerGetTokenCsReq, PlayerGetTokenScRsp>(new PlayerGetTokenCsReq());
|
||||
var login = await Client.RequestAsync<PlayerLoginCsReq, PlayerLoginScRsp>(new PlayerLoginCsReq { LoginRandom = 1 });
|
||||
Assert.Equal(0u, login.Retcode);
|
||||
Client.ClearInbox();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Client.Dispose();
|
||||
await _cancellation.CancelAsync();
|
||||
await Task.WhenAny(_server, Task.Delay(2000));
|
||||
}
|
||||
|
||||
private static int FreeUdpPort()
|
||||
{
|
||||
using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
probe.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)probe.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasData => TestWorld.Available;
|
||||
|
||||
[Fact]
|
||||
public async Task RosterAndTeamsLoad()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var avatars = await client.RequestAsync<GetAvatarDataCsReq, GetAvatarDataScRsp>(new GetAvatarDataCsReq { IsGetAll = true });
|
||||
Assert.True(avatars.AvatarList.Count >= 4, $"only {avatars.AvatarList.Count} avatars");
|
||||
Assert.All(avatars.AvatarPathDataInfoList, p => Assert.NotEmpty(p.AvatarPathSkillTree));
|
||||
Assert.Contains(8001u, avatars.BasicTypeIdList);
|
||||
|
||||
var all = await client.RequestAsync<GetAllLineupDataCsReq, GetAllLineupDataScRsp>(new GetAllLineupDataCsReq());
|
||||
Assert.Equal(LineupBook.SquadCount, all.LineupList.Count);
|
||||
Assert.Equal(0u, all.CurIndex);
|
||||
Assert.NotEmpty(all.LineupList[0].AvatarList);
|
||||
Assert.All(all.LineupList, l => Assert.Equal(ExtraLineupType.LineupNone, l.ExtraLineupType));
|
||||
Assert.Equal([0u, 1u, 2u, 3u, 4u, 5u], all.LineupList.Select(l => l.Index));
|
||||
|
||||
var current = await client.RequestAsync<GetCurLineupDataCsReq, GetCurLineupDataScRsp>(new GetCurLineupDataCsReq());
|
||||
Assert.Equal(all.LineupList[0].AvatarList.Count, current.Lineup.AvatarList.Count);
|
||||
Assert.Equal(LineupBook.BaseMaxMp, current.Lineup.MaxMp);
|
||||
|
||||
var picker = await client.RequestAsync<GetLineupAvatarDataCsReq, GetLineupAvatarDataScRsp>(new GetLineupAvatarDataCsReq());
|
||||
Assert.True(picker.AvatarDataList.Count >= avatars.AvatarList.Count);
|
||||
Assert.All(picker.AvatarDataList, a => Assert.Equal(Player.FullHp, a.Hp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TeamEditsRoundTripAndRefreshTheScene()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var scene = await client.RequestAsync<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(new GetCurSceneInfoCsReq());
|
||||
var actorsBefore = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).Where(e => e.Actor is not null).ToList();
|
||||
Assert.NotEmpty(actorsBefore);
|
||||
client.ClearInbox();
|
||||
|
||||
var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList();
|
||||
var a = owned[0];
|
||||
var b = owned[1];
|
||||
var c = owned[2];
|
||||
|
||||
var replace = new ReplaceLineupCsReq { Index = 0, LeaderSlot = 1 };
|
||||
replace.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = a, AvatarType = AvatarType.AvatarFormalType });
|
||||
replace.LineupSlotList.Add(new LineupSlotData { Slot = 1, Id = b, AvatarType = AvatarType.AvatarFormalType });
|
||||
|
||||
var replaced = await client.RequestAsync<ReplaceLineupCsReq, ReplaceLineupScRsp>(replace);
|
||||
Assert.Equal(0u, replaced.Retcode);
|
||||
|
||||
var sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal([a, b], sync.Lineup.AvatarList.Select(x => x.Id));
|
||||
Assert.Equal(1u, sync.Lineup.LeaderSlot);
|
||||
|
||||
// the party model changed, so the scene had to swap actors in place
|
||||
await client.PumpAsync(TimeSpan.FromMilliseconds(300));
|
||||
var refreshes = client.Take<SceneGroupRefreshScNotify>();
|
||||
Assert.NotEmpty(refreshes);
|
||||
Assert.Contains(refreshes.SelectMany(r => r.GroupRefreshList), g => g.GroupId == 0 && g.RefreshEntity.Count > 0);
|
||||
|
||||
var join = await client.RequestAsync<JoinLineupCsReq, JoinLineupScRsp>(new JoinLineupCsReq { Index = 0, Slot = 2, OPCJCDKMJEI = c });
|
||||
Assert.Equal(0u, join.Retcode);
|
||||
sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal([a, b, c], sync.Lineup.AvatarList.Select(x => x.Id));
|
||||
Assert.Equal([0u, 1u, 2u], sync.Lineup.AvatarList.Select(x => x.Slot));
|
||||
|
||||
var swap = await client.RequestAsync<SwapLineupCsReq, SwapLineupScRsp>(new SwapLineupCsReq { Index = 0, MPIEGFCNBEN = 0, ENBIBCPPLDC = 2 });
|
||||
Assert.Equal(0u, swap.Retcode);
|
||||
sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal([c, b, a], sync.Lineup.AvatarList.Select(x => x.Id));
|
||||
// the leader is a character, not a chair
|
||||
Assert.Equal(1u, sync.Lineup.LeaderSlot);
|
||||
|
||||
var leader = await client.RequestAsync<ChangeLineupLeaderCsReq, ChangeLineupLeaderScRsp>(new ChangeLineupLeaderCsReq { Slot = 2 });
|
||||
Assert.Equal(0u, leader.Retcode);
|
||||
Assert.Equal(2u, leader.Slot);
|
||||
sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal(2u, sync.Lineup.LeaderSlot);
|
||||
|
||||
var quit = await client.RequestAsync<QuitLineupCsReq, QuitLineupScRsp>(new QuitLineupCsReq { Index = 0, BaseAvatarId = b });
|
||||
Assert.Equal(0u, quit.Retcode);
|
||||
sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal([c, a], sync.Lineup.AvatarList.Select(x => x.Id));
|
||||
Assert.Equal([0u, 2u], sync.Lineup.AvatarList.Select(x => x.Slot));
|
||||
|
||||
// an empty squad cannot become the walking team
|
||||
var empty = await client.RequestAsync<SwitchLineupIndexCsReq, SwitchLineupIndexScRsp>(new SwitchLineupIndexCsReq { Index = 3 });
|
||||
Assert.Equal((uint)Retcode.RetLineupIsEmpty, empty.Retcode);
|
||||
|
||||
var named = await client.RequestAsync<SetLineupNameCsReq, SetLineupNameScRsp>(new SetLineupNameCsReq { Index = 3, Name = "Bench" });
|
||||
Assert.Equal("Bench", named.Name);
|
||||
|
||||
var fill = new ReplaceLineupCsReq { Index = 3 };
|
||||
fill.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = b, AvatarType = AvatarType.AvatarFormalType });
|
||||
await client.RequestAsync<ReplaceLineupCsReq, ReplaceLineupScRsp>(fill);
|
||||
|
||||
var switched = await client.RequestAsync<SwitchLineupIndexCsReq, SwitchLineupIndexScRsp>(new SwitchLineupIndexCsReq { Index = 3 });
|
||||
Assert.Equal(0u, switched.Retcode);
|
||||
|
||||
var current = await client.RequestAsync<GetCurLineupDataCsReq, GetCurLineupDataScRsp>(new GetCurLineupDataCsReq());
|
||||
Assert.Equal("Bench", current.Lineup.Name);
|
||||
Assert.Equal([b], current.Lineup.AvatarList.Select(x => x.Id));
|
||||
Assert.Equal(3u, current.Lineup.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HittingAMonsterStartsAndSettlesAFight()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var scene = await client.RequestAsync<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(new GetCurSceneInfoCsReq());
|
||||
var entities = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).ToList();
|
||||
var actor = entities.First(e => e.Actor is not null);
|
||||
var monster = entities.First(e => e.NpcMonster is not null && e.NpcMonster.EventId != 0);
|
||||
client.ClearInbox();
|
||||
|
||||
var cast = new SceneCastSkillCsReq
|
||||
{
|
||||
CastEntityId = actor.EntityId,
|
||||
AttackedByEntityId = actor.EntityId,
|
||||
SkillIndex = 0,
|
||||
};
|
||||
cast.HitTargetEntityIdList.Add(monster.EntityId);
|
||||
cast.AssistMonsterEntityIdList.Add(monster.EntityId);
|
||||
|
||||
var response = await client.RequestAsync<SceneCastSkillCsReq, SceneCastSkillScRsp>(cast);
|
||||
Assert.Equal(0u, response.Retcode);
|
||||
Assert.NotNull(response.BattleInfo);
|
||||
|
||||
var hit = Assert.Single(response.MonsterBattleInfo);
|
||||
Assert.Equal(monster.EntityId, hit.TargetMonsterEntityId);
|
||||
Assert.Equal(MonsterBattleType.TriggerBattle, hit.MonsterBattleType);
|
||||
|
||||
var battle = response.BattleInfo;
|
||||
output.WriteLine($"stage {battle.StageId}: {battle.BattleAvatarList.Count} avatars, {battle.MonsterWaveList.Count} waves, {battle.BuffList.Count} buffs");
|
||||
Assert.NotEqual(0u, battle.StageId);
|
||||
Assert.NotEmpty(battle.BattleAvatarList);
|
||||
Assert.NotEmpty(battle.MonsterWaveList);
|
||||
// the overworld has no cycle limit; only challenges count rounds
|
||||
Assert.Equal(0u, battle.RoundsLimit);
|
||||
Assert.Equal((uint)battle.MonsterWaveList.Count, battle.MonsterWaveLength);
|
||||
Assert.Equal(Enumerable.Range(0, battle.BattleAvatarList.Count).Select(i => (uint)i), battle.BattleAvatarList.Select(a => a.Index));
|
||||
|
||||
// the attacker opened with a normal attack, so the entry buff says so
|
||||
var opener = battle.BuffList.FirstOrDefault(b => b.Id is >= 1000111 and <= 1000117);
|
||||
Assert.NotNull(opener);
|
||||
Assert.Equal(1f, opener.DynamicValues["SkillIndex"]);
|
||||
|
||||
// the client can ask again mid-fight and gets the same seed back
|
||||
var current = await client.RequestAsync<GetCurBattleInfoCsReq, GetCurBattleInfoScRsp>(new GetCurBattleInfoCsReq());
|
||||
Assert.Equal(battle.LogicRandomSeed, current.BattleInfo.LogicRandomSeed);
|
||||
|
||||
var result = new PVEBattleResultCsReq
|
||||
{
|
||||
BattleId = battle.BattleId,
|
||||
StageId = battle.StageId,
|
||||
EndStatus = BattleEndStatus.BattleEndWin,
|
||||
Stt = new BattleStatistics { RoundCnt = 3 },
|
||||
};
|
||||
|
||||
foreach (var avatar in battle.BattleAvatarList)
|
||||
{
|
||||
result.Stt.BattleAvatarList.Add(new AvatarBattleInfo
|
||||
{
|
||||
Id = avatar.Id,
|
||||
AvatarType = AvatarType.AvatarFormalType,
|
||||
AvatarStatus = new AvatarProperty { MaxHp = 1000, LeftHp = 500, MaxSp = 120, LeftSp = 60 },
|
||||
});
|
||||
}
|
||||
|
||||
var settled = await client.RequestAsync<PVEBattleResultCsReq, PVEBattleResultScRsp>(result);
|
||||
Assert.Equal(0u, settled.Retcode);
|
||||
Assert.Equal(BattleEndStatus.BattleEndWin, settled.EndStatus);
|
||||
Assert.Equal(battle.BattleId, settled.BattleId);
|
||||
|
||||
// the monster is gone from the map and the party remembers its wounds
|
||||
var refresh = await client.ExpectAsync<SceneGroupRefreshScNotify>();
|
||||
Assert.Contains(refresh.GroupRefreshList.SelectMany(g => g.RefreshEntity), e => e.DeleteEntity == monster.EntityId);
|
||||
|
||||
var sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.All(sync.Lineup.AvatarList, a => Assert.Equal(5000u, a.Hp));
|
||||
Assert.All(sync.Lineup.AvatarList, a => Assert.Equal(60u, a.SpBar.CurSp));
|
||||
|
||||
var after = await client.RequestAsync<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(new GetCurSceneInfoCsReq());
|
||||
Assert.DoesNotContain(after.Scene.EntityGroupList.SelectMany(g => g.EntityList), e => e.EntityId == monster.EntityId);
|
||||
|
||||
// nothing is left to fight, so no battle is offered
|
||||
var idle = await client.RequestAsync<GetCurBattleInfoCsReq, GetCurBattleInfoScRsp>(new GetCurBattleInfoCsReq());
|
||||
Assert.Equal(0u, idle.BattleInfo.StageId);
|
||||
Assert.Equal(BattleEndStatus.BattleEndWin, idle.LastEndStatus);
|
||||
}
|
||||
|
||||
// a calyx is how an older MOC/PF/AS stage gets replayed: it runs whatever srtools uploaded
|
||||
[Fact]
|
||||
public async Task CalyxPlaysTheConfiguredBattle()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var configured = World.Player.BattleConfig;
|
||||
|
||||
if (configured.StageId == 0)
|
||||
{
|
||||
output.WriteLine("no srtools battle_config; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new StartCocoonStageCsReq { CocoonId = 1001, Wave = 1, WorldLevel = 6, PropEntityId = 7 };
|
||||
|
||||
await using (var auto = new Harness("auto"))
|
||||
{
|
||||
await auto.LoginAsync();
|
||||
var rsp = await auto.Client.RequestAsync<StartCocoonStageCsReq, StartCocoonStageScRsp>(request);
|
||||
|
||||
Assert.Equal(0u, rsp.Retcode);
|
||||
Assert.Equal(7u, rsp.PropEntityId);
|
||||
Assert.Equal(configured.StageId, rsp.BattleInfo.StageId);
|
||||
}
|
||||
|
||||
// "stage" opts out and plays the calyx's own encounter
|
||||
await using var stages = new Harness("stage");
|
||||
await stages.LoginAsync();
|
||||
var own = await stages.Client.RequestAsync<StartCocoonStageCsReq, StartCocoonStageScRsp>(request);
|
||||
|
||||
Assert.Equal(0u, own.Retcode);
|
||||
Assert.NotEqual(configured.StageId, own.BattleInfo.StageId);
|
||||
Assert.NotNull(World.Data.GetStage(own.BattleInfo.StageId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TechniquesSpendPointsAndTeleportsRestoreThem()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var cost = await client.RequestAsync<SceneCastSkillCostMpCsReq, SceneCastSkillCostMpScRsp>(new SceneCastSkillCostMpCsReq { CastEntityId = 101001 });
|
||||
Assert.Equal(0u, cost.Retcode);
|
||||
|
||||
var update = await client.ExpectAsync<SceneCastSkillMpUpdateScNotify>();
|
||||
Assert.Equal(LineupBook.BaseMaxMp - 1, update.Mp);
|
||||
|
||||
var entry = new GameServerConfig().StartEntryId;
|
||||
var enter = await client.RequestAsync<EnterSceneCsReq, EnterSceneScRsp>(new EnterSceneCsReq { EntryId = entry, TeleportId = new GameServerConfig().StartTeleportId });
|
||||
Assert.Equal(0u, enter.Retcode);
|
||||
|
||||
var scene = await client.ExpectAsync<EnterSceneByServerScNotify>();
|
||||
Assert.Equal(entry, scene.Scene.EntryId);
|
||||
Assert.Equal(LineupBook.BaseMaxMp, scene.Lineup.Mp);
|
||||
Assert.NotEmpty(scene.Scene.EntityGroupList);
|
||||
}
|
||||
|
||||
// the party bar only redraws from the notify, and the client stops listening once the
|
||||
// request it sent is answered, so the notify has to be on the wire first
|
||||
[Fact]
|
||||
public async Task TheTeamIsPushedBeforeTheEditIsAnswered()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
await client.RequestAsync<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(new GetCurSceneInfoCsReq());
|
||||
|
||||
var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList();
|
||||
client.ClearInbox();
|
||||
|
||||
var replace = new ReplaceLineupCsReq { Index = 0, LeaderSlot = 0 };
|
||||
replace.LineupSlotList.Add(new LineupSlotData { Slot = 0, Id = owned[2], AvatarType = AvatarType.AvatarFormalType });
|
||||
replace.LineupSlotList.Add(new LineupSlotData { Slot = 1, Id = owned[3], AvatarType = AvatarType.AvatarFormalType });
|
||||
|
||||
await client.RequestAsync<ReplaceLineupCsReq, ReplaceLineupScRsp>(replace);
|
||||
|
||||
var order = client.ArrivalOrder.ToList();
|
||||
var sync = order.IndexOf(nameof(SyncLineupNotify));
|
||||
var scene = order.IndexOf(nameof(SceneGroupRefreshScNotify));
|
||||
var answer = order.IndexOf(nameof(ReplaceLineupScRsp));
|
||||
|
||||
Assert.True(sync >= 0, $"no SyncLineupNotify, got [{string.Join(", ", order)}]");
|
||||
Assert.True(scene >= 0, $"no SceneGroupRefreshScNotify, got [{string.Join(", ", order)}]");
|
||||
Assert.True(answer >= 0);
|
||||
Assert.True(scene < answer, $"scene refresh must precede the answer, got [{string.Join(", ", order)}]");
|
||||
Assert.True(sync < answer, $"lineup sync must precede the answer, got [{string.Join(", ", order)}]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryChallengeFloorIsReachable()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
|
||||
var list = await harness.Client.RequestAsync<GetChallengeCsReq, GetChallengeScRsp>(new GetChallengeCsReq());
|
||||
|
||||
Assert.Equal(World.Data.Challenges.Count, list.ChallengeList.Count);
|
||||
Assert.Equal(World.Data.ChallengeGroups.Count, list.ChallengeGroupList.Count);
|
||||
|
||||
// a floor with no stars locks the one after it, so none may be left at zero
|
||||
Assert.All(list.ChallengeList, c => Assert.True(c.Star > 0, $"floor {c.ChallengeId} is locked"));
|
||||
|
||||
// pure fiction and apocalyptic shadow open on score, taken from their target rows
|
||||
foreach (var challenge in World.Data.Challenges.Values.Where(c => c.Kind != Data.Excel.ChallengeKind.MemoryOfChaos))
|
||||
{
|
||||
var reported = list.ChallengeList.First(c => c.ChallengeId == challenge.ID);
|
||||
var target = challenge.ChallengeTargetID
|
||||
.Select(id => World.Data.ChallengeTargets.GetValueOrDefault(id))
|
||||
.Where(t => t?.ChallengeTargetType == "TOTAL_SCORE")
|
||||
.Select(t => t!.ChallengeTargetParam1)
|
||||
.DefaultIfEmpty(0u)
|
||||
.Max();
|
||||
|
||||
Assert.True(reported.ScoreId >= target, $"floor {challenge.ID} scored {reported.ScoreId}, needs {target}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChatCommandsAnswerThroughTheBotFriend()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var friends = await client.RequestAsync<GetFriendListInfoCsReq, GetFriendListInfoScRsp>(new GetFriendListInfoCsReq());
|
||||
var bot = Assert.Single(friends.FriendList);
|
||||
Assert.Equal(GameServer.Handlers.ChatHandlers.BotUid, bot.PlayerInfo.Uid);
|
||||
|
||||
var help = await Say(client, "/help");
|
||||
Assert.Contains("/tp", help);
|
||||
|
||||
var unknown = await Say(client, "/nope");
|
||||
Assert.Contains("Unknown command", unknown);
|
||||
|
||||
await Say(client, "/wl 3");
|
||||
var basic = await client.ExpectAsync<PlayerSyncScNotify>();
|
||||
Assert.Equal(3u, basic.BasicInfo.WorldLevel);
|
||||
|
||||
await Say(client, "/mp 2");
|
||||
var sync = await client.ExpectAsync<SyncLineupNotify>();
|
||||
Assert.Equal(2u, sync.Lineup.Mp);
|
||||
|
||||
// the next fight plays the requested stage, whatever was hit
|
||||
var reply = await Say(client, "/stage 1022010");
|
||||
Assert.Contains("1022010", reply);
|
||||
|
||||
var scene = await client.RequestAsync<GetCurSceneInfoCsReq, GetCurSceneInfoScRsp>(new GetCurSceneInfoCsReq());
|
||||
var entities = scene.Scene.EntityGroupList.SelectMany(g => g.EntityList).ToList();
|
||||
var actor = entities.First(e => e.Actor is not null);
|
||||
var monster = entities.First(e => e.NpcMonster is not null);
|
||||
|
||||
var cast = new SceneCastSkillCsReq { CastEntityId = actor.EntityId, AttackedByEntityId = actor.EntityId };
|
||||
cast.HitTargetEntityIdList.Add(monster.EntityId);
|
||||
var battle = await client.RequestAsync<SceneCastSkillCsReq, SceneCastSkillScRsp>(cast);
|
||||
Assert.Equal(1022010u, battle.BattleInfo.StageId);
|
||||
Assert.Equal(3u, battle.BattleInfo.WorldLevel);
|
||||
}
|
||||
|
||||
private static async Task<string> Say(TestClient client, string text)
|
||||
{
|
||||
client.ClearInbox();
|
||||
|
||||
var message = new SendMsgCsReq
|
||||
{
|
||||
ChatType = ChatType.Private,
|
||||
MessageDatas = new MessageChatData
|
||||
{
|
||||
MessageType = MsgType.CustomText,
|
||||
ChatData = new ChatData { MessageText = text },
|
||||
},
|
||||
};
|
||||
message.TargetList.Add(GameServer.Handlers.ChatHandlers.BotUid);
|
||||
|
||||
var sent = await client.RequestAsync<SendMsgCsReq, SendMsgScRsp>(message);
|
||||
Assert.Equal(0u, sent.Retcode);
|
||||
|
||||
var reply = await client.ExpectAsync<RevcMsgScNotify>();
|
||||
Assert.Equal(GameServer.Handlers.ChatHandlers.BotUid, reply.SourceUid);
|
||||
return reply.RecvMessageData.MessageDatas[0].ChatData.MessageText;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemoryOfChaosRunsBothNodesAndSettles()
|
||||
{
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var floor = World.Data.Challenges.Values
|
||||
.Where(c => c.Kind == Data.Excel.ChallengeKind.MemoryOfChaos && c.NodeCount == 2)
|
||||
.OrderBy(c => c.ID)
|
||||
.FirstOrDefault(c => new ChallengeState().StartChallenge(World.Data, c, 0, 0, [1001]) &&
|
||||
new ChallengeState().StartChallenge(World.Data, c, 1, 0, [1001]));
|
||||
|
||||
if (floor is null)
|
||||
{
|
||||
output.WriteLine("no two-node MOC floor resolves; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var harness = new Harness();
|
||||
await harness.LoginAsync();
|
||||
var client = harness.Client;
|
||||
|
||||
var owned = World.OwnedAvatarIds.Select(World.Data.BaseAvatarId).Distinct().ToList();
|
||||
var first = owned.Take(2).ToList();
|
||||
var second = owned.Skip(2).Take(2).ToList();
|
||||
|
||||
var start = new StartChallengeCsReq { ChallengeId = floor.ID };
|
||||
start.AvatarLineupFirst.AddRange(first.Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType }));
|
||||
start.AvatarLineupSecond.AddRange(second.Select(id => new AvatarIdentifier { Id = id, AvatarType = AvatarType.AvatarFormalType }));
|
||||
|
||||
var started = await client.RequestAsync<StartChallengeCsReq, StartChallengeScRsp>(start);
|
||||
Assert.Equal(0u, started.Retcode);
|
||||
Assert.Equal(floor.ID, started.CurChallenge.ChallengeId);
|
||||
Assert.Equal(ChallengeStatus.ChallengeDoing, started.CurChallenge.Status);
|
||||
Assert.Equal(ExtraLineupType.LineupChallenge, started.CurChallenge.ExtraLineupType);
|
||||
Assert.Equal(2, started.LineupList.Count);
|
||||
Assert.Equal(first, started.LineupList[0].AvatarList.Select(a => a.Id));
|
||||
Assert.Equal(second, started.LineupList[1].AvatarList.Select(a => a.Id));
|
||||
Assert.True(started.LineupList[0].IsVirtual);
|
||||
|
||||
var actors = started.Scene.EntityGroupList.SelectMany(g => g.EntityList).Where(e => e.Actor is not null).ToList();
|
||||
Assert.Equal(first.Count, actors.Count);
|
||||
|
||||
await FightNodeAsync(client, started.Scene, first.Count, expectSettle: false);
|
||||
|
||||
// the second half arrives as a fresh scene with the second team
|
||||
var lineupNotify = await client.ExpectAsync<ChallengeLineupNotify>();
|
||||
Assert.Equal(ExtraLineupType.LineupChallenge2, lineupNotify.ExtraLineupType);
|
||||
|
||||
var node2 = await client.ExpectAsync<EnterSceneByServerScNotify>();
|
||||
Assert.Equal(second, node2.Lineup.AvatarList.Select(a => a.Id));
|
||||
Assert.Equal(ExtraLineupType.LineupChallenge2, node2.Lineup.ExtraLineupType);
|
||||
|
||||
var cur = await client.RequestAsync<GetCurChallengeCsReq, GetCurChallengeScRsp>(new GetCurChallengeCsReq());
|
||||
Assert.Equal(ExtraLineupType.LineupChallenge2, cur.CurChallenge.ExtraLineupType);
|
||||
|
||||
await FightNodeAsync(client, node2.Scene, second.Count, expectSettle: true);
|
||||
|
||||
var settle = await client.ExpectAsync<ChallengeSettleNotify>();
|
||||
Assert.Equal(floor.ID, settle.ChallengeId);
|
||||
Assert.True(settle.IsWin);
|
||||
Assert.True(settle.Star > 0, "a full clear should award stars");
|
||||
|
||||
// the record shows up on the floor list afterwards
|
||||
var list = await client.RequestAsync<GetChallengeCsReq, GetChallengeScRsp>(new GetChallengeCsReq());
|
||||
var record = list.ChallengeList.First(c => c.ChallengeId == floor.ID);
|
||||
Assert.Equal(settle.Star, record.Star);
|
||||
|
||||
var left = await client.RequestAsync<LeaveChallengeCsReq, LeaveChallengeScRsp>(new LeaveChallengeCsReq());
|
||||
Assert.Equal(0u, left.Retcode);
|
||||
|
||||
var world = await client.ExpectAsync<EnterSceneByServerScNotify>();
|
||||
Assert.Equal(ExtraLineupType.LineupNone, world.Lineup.ExtraLineupType);
|
||||
Assert.Equal(new GameServerConfig().StartEntryId, world.Scene.EntryId);
|
||||
}
|
||||
|
||||
private static async Task FightNodeAsync(TestClient client, SceneInfo scene, int partySize, bool expectSettle)
|
||||
{
|
||||
var entities = scene.EntityGroupList.SelectMany(g => g.EntityList).ToList();
|
||||
var actor = entities.First(e => e.Actor is not null);
|
||||
var monster = entities.First(e => e.NpcMonster is not null);
|
||||
client.ClearInbox();
|
||||
|
||||
var cast = new SceneCastSkillCsReq { CastEntityId = actor.EntityId, AttackedByEntityId = actor.EntityId, SkillIndex = 1 };
|
||||
cast.HitTargetEntityIdList.Add(monster.EntityId);
|
||||
|
||||
var response = await client.RequestAsync<SceneCastSkillCsReq, SceneCastSkillScRsp>(cast);
|
||||
var battle = response.BattleInfo;
|
||||
Assert.NotNull(battle);
|
||||
Assert.Equal(partySize, battle.BattleAvatarList.Count);
|
||||
Assert.NotEmpty(battle.MonsterWaveList);
|
||||
Assert.True(battle.RoundsLimit is > 0 and <= 30);
|
||||
|
||||
var result = new PVEBattleResultCsReq
|
||||
{
|
||||
BattleId = battle.BattleId,
|
||||
StageId = battle.StageId,
|
||||
EndStatus = BattleEndStatus.BattleEndWin,
|
||||
Stt = new BattleStatistics { RoundCnt = 2 },
|
||||
};
|
||||
|
||||
var settled = await client.RequestAsync<PVEBattleResultCsReq, PVEBattleResultScRsp>(result);
|
||||
Assert.Equal(BattleEndStatus.BattleEndWin, settled.EndStatus);
|
||||
await client.PumpAsync(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.Excel;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class GameDataTests(ITestOutputHelper output)
|
||||
{
|
||||
private static GameData? TryLoad(ITestOutputHelper output)
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
output.WriteLine("skipping: no game data sources found");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GameData.Load(sources, output.WriteLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadsEveryTable()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(data.Avatars.Count > 80, $"only {data.Avatars.Count} avatars");
|
||||
Assert.True(data.Stages.Count > 20_000, $"only {data.Stages.Count} stages");
|
||||
Assert.NotEmpty(data.Monsters);
|
||||
Assert.NotEmpty(data.Equipment);
|
||||
}
|
||||
|
||||
// an empty banner leaves the client's gacha red dot throwing once a frame
|
||||
[Fact]
|
||||
public void ReadsStandardBannerCeiling()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.NotEmpty(data.GachaCeilingAvatars);
|
||||
Assert.Contains(1003u, data.GachaCeilingAvatars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesMarch7th()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var march = data.GetAvatar(1001);
|
||||
Assert.NotNull(march);
|
||||
Assert.Equal("Ice", march.DamageType);
|
||||
Assert.Equal("Knight", march.AvatarBaseType);
|
||||
Assert.Equal(120u, march.SpMax);
|
||||
Assert.Equal(6, march.RankIDList.Count);
|
||||
|
||||
var tree = data.MaxedSkillTree(1001).ToList();
|
||||
Assert.True(tree.Count > 10, $"only {tree.Count} trace points");
|
||||
Assert.All(tree, p => Assert.True(p.Level > 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesStageMonsterWaves()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stage = data.GetStage(1022010);
|
||||
Assert.NotNull(stage);
|
||||
Assert.Equal("Cocoon", stage.StageType);
|
||||
Assert.Equal(16u, stage.Level);
|
||||
|
||||
var wave = Assert.Single(stage.MonsterList);
|
||||
Assert.Equal([8001010, 8001020, 8001010, 8001020], wave);
|
||||
|
||||
// obfuscated key names, matched by the '_'-prefixed value
|
||||
Assert.Equal("1", stage.StageConfigData["_Wave"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BetaOverlayAddsNewCharacters()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null || data.Sources.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1503 Pearl ships in the 4.6 beta dump only, not in prod 4.5.0
|
||||
var pearl = data.GetAvatar(1503);
|
||||
Assert.NotNull(pearl);
|
||||
Assert.Equal("Ice", pearl.DamageType);
|
||||
Assert.Equal("Elation", pearl.AvatarBaseType);
|
||||
Assert.True(data.MaxedSkillTree(1503).Count() >= 10);
|
||||
|
||||
// prod rows still present and not clobbered by the overlay
|
||||
Assert.NotNull(data.GetAvatar(1001));
|
||||
Assert.Equal(100101u, data.DefaultMazeBuffs[1001].BuffId);
|
||||
}
|
||||
|
||||
// 101 is the Space Anchor; the Chap00 boxes are the first MP/HP orbs the game shows
|
||||
[Fact]
|
||||
public void PropsAreClassifiedByEntityPath()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null || data.Props.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(data.Props[101].IsSpring);
|
||||
Assert.True(data.Props[100025].IsMpRecover);
|
||||
Assert.False(data.Props[100025].IsHpRecover);
|
||||
Assert.True(data.Props[100024].IsHpRecover);
|
||||
Assert.True(data.Props[100026].IsDestructible);
|
||||
Assert.False(data.Props[100026].IsMpRecover || data.Props[100026].IsHpRecover);
|
||||
Assert.Contains(100026u, data.DestructiblePropIds);
|
||||
Assert.DoesNotContain(101u, data.DestructiblePropIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverworldEventsResolveToStages()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 30001011 is MOC floor 1's encounter; its event is also its stage
|
||||
Assert.Equal(30001011u, data.ResolveStageForEvent(30001011, 6));
|
||||
Assert.Equal(0u, data.ResolveStageForEvent(0, 6));
|
||||
|
||||
// a real overworld monster from the start scene resolves at every world level
|
||||
var monster = data.GetScene(new GameServerConfig().StartEntryId)!.Monsters.First(m => m.EventId != 0);
|
||||
for (var level = 0u; level <= 6; level++)
|
||||
{
|
||||
var stage = data.ResolveStageForEvent(monster.EventId, level);
|
||||
Assert.NotEqual(0u, stage);
|
||||
Assert.NotNull(data.GetStage(stage));
|
||||
}
|
||||
|
||||
// calyx and farm rows fall back to the closest lower world level
|
||||
Assert.NotNull(data.FindCocoon(1001, 6));
|
||||
Assert.NotNull(data.FindCocoon(1001, 0));
|
||||
Assert.Null(data.FindCocoon(999999, 6));
|
||||
Assert.NotNull(data.FindFarmElement(1101, 6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChallengeTablesLoad()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(data.Challenges.Count > 600, $"only {data.Challenges.Count} challenge floors");
|
||||
Assert.NotEmpty(data.MapEntrances);
|
||||
Assert.NotEmpty(data.MazePlanes);
|
||||
|
||||
var kinds = data.Challenges.Values.GroupBy(c => c.Kind).ToDictionary(g => g.Key, g => g.Count());
|
||||
foreach (var (kind, count) in kinds)
|
||||
{
|
||||
output.WriteLine($"{kind}: {count}");
|
||||
}
|
||||
|
||||
Assert.All(Enum.GetValues<ChallengeKind>(), k => Assert.True(kinds.ContainsKey(k), $"no {k} floors"));
|
||||
}
|
||||
|
||||
// dimbreath's ChallengePeakConfig has no MapEntranceID column at all; the beta dump does.
|
||||
// whole-row merging would drop it and leave AA with nowhere to teleport to.
|
||||
[Fact]
|
||||
public void FieldMergeRecoversBetaOnlyColumns()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null || data.Sources.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var playable = data.ChallengePeaks.Values.Where(p => p.IsPlayable).ToList();
|
||||
output.WriteLine($"{playable.Count}/{data.ChallengePeaks.Count} AA stages have scene fields");
|
||||
Assert.NotEmpty(playable);
|
||||
|
||||
// 101 exists in both sources; only the beta one carries MapEntranceID
|
||||
var first = data.ChallengePeaks[101];
|
||||
Assert.NotEqual(0u, first.MapEntranceID);
|
||||
Assert.NotEmpty(first.NpcMonsterIDList);
|
||||
Assert.NotEmpty(first.TagList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntranceResolvesToAPlane()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var challenge = data.Challenges.Values.First(c => c.MapEntranceID > 0);
|
||||
var resolved = data.ResolveEntrance(challenge.MapEntranceID);
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.NotEqual(0u, resolved.Value.Entrance.FloorID);
|
||||
Assert.NotNull(resolved.Value.Plane);
|
||||
Assert.NotEqual(0u, resolved.Value.Plane!.WorldID);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesTechniqueAndGlobalBuffs()
|
||||
{
|
||||
var data = TryLoad(output);
|
||||
if (data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var march = data.DefaultMazeBuffs[1001];
|
||||
Assert.Equal(100101u, march.BuffId);
|
||||
Assert.Equal(2u, march.SkillIndex);
|
||||
|
||||
// castorice and silver wolf are the only two with a global buff
|
||||
Assert.Equal(2, data.GlobalBuffs.Count);
|
||||
Assert.Contains(data.GlobalBuffs, b => b.AvatarID == 1407);
|
||||
Assert.Contains(data.GlobalBuffs, b => b.AvatarID == 1506);
|
||||
}
|
||||
}
|
||||
|
||||
public class PathSkillTreeTests(ITestOutputHelper output)
|
||||
{
|
||||
// AvatarPathData.point_id is the anchor index ("Point01" -> 1), not the raw point id.
|
||||
// sending raw point ids gives the client something it cannot resolve.
|
||||
[Fact]
|
||||
public void AnchorIndexParsesFromAnchorType()
|
||||
{
|
||||
Assert.Equal(1u, new AvatarSkillTreeExcel { AnchorType = "Point01" }.AnchorIndex);
|
||||
Assert.Equal(12u, new AvatarSkillTreeExcel { AnchorType = "Point12" }.AnchorIndex);
|
||||
Assert.Equal(0u, new AvatarSkillTreeExcel { AnchorType = "" }.AnchorIndex);
|
||||
Assert.Equal(0u, new AvatarSkillTreeExcel { AnchorType = "Point" }.AnchorIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryPlayableAvatarHasAnchoredTracePoints()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = GameData.Load(sources);
|
||||
var playable = data.Avatars.Keys.Where(id => id is (> 1000 and < 2000) or (>= 8001 and <= 8010)).ToList();
|
||||
|
||||
var missing = playable.Where(id => !data.MaxedPathSkillTree(id).Any()).ToList();
|
||||
output.WriteLine($"{playable.Count} playable avatars, {missing.Count} without anchored trace points");
|
||||
|
||||
Assert.Empty(missing);
|
||||
|
||||
// anchors are small indices, never raw point ids
|
||||
foreach (var (anchor, _) in data.MaxedPathSkillTree(1001))
|
||||
{
|
||||
Assert.InRange(anchor, 1u, 100u);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiPathAvatarsAreKnown()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = GameData.Load(sources);
|
||||
|
||||
Assert.Contains(8001u, data.MultiPathAvatarIds);
|
||||
Assert.Contains(1001u, data.MultiPathAvatarIds);
|
||||
Assert.Contains(1224u, data.MultiPathAvatarIds);
|
||||
Assert.DoesNotContain(1003u, data.MultiPathAvatarIds);
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.GameServer.Net;
|
||||
using CapySR.Protocol;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class GatewayIntegrationTests
|
||||
{
|
||||
// one world for the whole class; loading the tables is the slow part
|
||||
private static GameWorld World => TestWorld.Current;
|
||||
|
||||
[Fact]
|
||||
public async Task ClientCompletesHandshakeAndLogin()
|
||||
{
|
||||
if (!TestWorld.Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var port = FreeUdpPort();
|
||||
// Persist off: a test must not read or write the machine's saved profile
|
||||
var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Nickname = "Capybara", Persist = false } };
|
||||
var handlers = HandlerRegistry.Build(NullLogger.Instance);
|
||||
var gateway = new KcpGateway(config, handlers, World, NullLogger<KcpGateway>.Instance);
|
||||
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var server = gateway.RunAsync(cancellation.Token);
|
||||
|
||||
using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port));
|
||||
await client.ConnectAsync();
|
||||
|
||||
var token = await client.RequestAsync<PlayerGetTokenCsReq, PlayerGetTokenScRsp>(new PlayerGetTokenCsReq());
|
||||
Assert.Equal(0u, token.Retcode);
|
||||
Assert.Equal(config.GameServer.Uid, token.Uid);
|
||||
|
||||
var login = await client.RequestAsync<PlayerLoginCsReq, PlayerLoginScRsp>(
|
||||
new PlayerLoginCsReq { LoginRandom = 4242 });
|
||||
|
||||
Assert.Equal(0u, login.Retcode);
|
||||
Assert.Equal(4242u, login.LoginRandom);
|
||||
Assert.Equal("Capybara", login.BasicInfo.Nickname);
|
||||
Assert.Equal(6u, login.BasicInfo.WorldLevel);
|
||||
|
||||
var heartbeat = await client.RequestAsync<PlayerHeartBeatCsReq, PlayerHeartBeatScRsp>(
|
||||
new PlayerHeartBeatCsReq { ClientTimeMs = 1234 });
|
||||
|
||||
Assert.Equal(1234u, heartbeat.ClientTimeMs);
|
||||
|
||||
await cancellation.CancelAsync();
|
||||
await Task.WhenAny(server, Task.Delay(2000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmodelledRequestStillGetsAnswered()
|
||||
{
|
||||
if (!TestWorld.Available)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var port = FreeUdpPort();
|
||||
var config = new ServerConfig { GameServer = { Host = "127.0.0.1", Port = port, Persist = false } };
|
||||
var handlers = HandlerRegistry.Build(NullLogger.Instance);
|
||||
var gateway = new KcpGateway(config, handlers, World, NullLogger<KcpGateway>.Instance);
|
||||
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var server = gateway.RunAsync(cancellation.Token);
|
||||
|
||||
using var client = new TestClient(new IPEndPoint(IPAddress.Loopback, port));
|
||||
await client.ConnectAsync();
|
||||
|
||||
var response = await client.RequestAsync<GetBagCsReq, GetBagScRsp>(new GetBagCsReq());
|
||||
Assert.NotNull(response);
|
||||
|
||||
await cancellation.CancelAsync();
|
||||
await Task.WhenAny(server, Task.Delay(2000));
|
||||
}
|
||||
|
||||
private static int FreeUdpPort()
|
||||
{
|
||||
using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
probe.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)probe.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestClient(IPEndPoint server) : IDisposable
|
||||
{
|
||||
private readonly Socket _socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
private readonly List<NetPacket> _inbox = [];
|
||||
private Kcp.Kcp? _kcp;
|
||||
|
||||
public async Task ConnectAsync()
|
||||
{
|
||||
var connect = new NetOperation(NetOperation.ConnectHead, 0, 0, 1234, NetOperation.ConnectTail);
|
||||
await _socket.SendToAsync(connect.ToArray(), SocketFlags.None, server);
|
||||
|
||||
var buffer = new byte[1500];
|
||||
var result = await ReceiveAsync(buffer, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.True(NetOperation.TryRead(buffer.AsSpan(0, result), out var accept));
|
||||
Assert.Equal(NetOperation.AcceptHead, accept.Head);
|
||||
Assert.Equal(NetOperation.AcceptTail, accept.Tail);
|
||||
|
||||
_kcp = new Kcp.Kcp(accept.Param1, accept.Param2, data =>
|
||||
_socket.SendTo(data, SocketFlags.None, server));
|
||||
|
||||
_kcp.SetNoDelay(true, 10, 2, true);
|
||||
_kcp.SetWindowSize(256, 256);
|
||||
}
|
||||
|
||||
private uint _clock;
|
||||
|
||||
private readonly List<string> _arrival = [];
|
||||
|
||||
// notifies the server pushed alongside the responses, in arrival order
|
||||
public IReadOnlyList<NetPacket> Inbox => _inbox;
|
||||
|
||||
// packet names exactly as they came off the wire, so ordering can be asserted
|
||||
public IReadOnlyList<string> ArrivalOrder => _arrival;
|
||||
|
||||
public async Task<TResponse> RequestAsync<TRequest, TResponse>(TRequest request)
|
||||
where TRequest : Google.Protobuf.IMessage<TRequest>
|
||||
where TResponse : Google.Protobuf.IMessage<TResponse>, new()
|
||||
{
|
||||
var expected = ProtocolRegistry.GetCmdId<TResponse>();
|
||||
|
||||
_kcp!.Send(NetPacket.Create(request).ToArray());
|
||||
_kcp.Flush();
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
foreach (var packet in _inbox)
|
||||
{
|
||||
if (packet.CmdId == expected)
|
||||
{
|
||||
_inbox.Remove(packet);
|
||||
return packet.ParseBody<TResponse>();
|
||||
}
|
||||
}
|
||||
|
||||
await PumpOnceAsync(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"never received {ProtocolRegistry.GetName(expected)}");
|
||||
}
|
||||
|
||||
// keep reading for a while so pushes that trail a response land in the inbox
|
||||
public async Task PumpAsync(TimeSpan duration)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + duration;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
await PumpOnceAsync(TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
}
|
||||
|
||||
public List<T> Take<T>() where T : Google.Protobuf.IMessage<T>, new()
|
||||
{
|
||||
var cmdId = ProtocolRegistry.GetCmdId<T>();
|
||||
var taken = new List<T>();
|
||||
|
||||
foreach (var packet in _inbox.Where(p => p.CmdId == cmdId).ToList())
|
||||
{
|
||||
taken.Add(packet.ParseBody<T>());
|
||||
_inbox.Remove(packet);
|
||||
}
|
||||
|
||||
return taken;
|
||||
}
|
||||
|
||||
public async Task<T> ExpectAsync<T>(TimeSpan? timeout = null) where T : Google.Protobuf.IMessage<T>, new()
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var found = Take<T>();
|
||||
if (found.Count > 0)
|
||||
{
|
||||
return found[0];
|
||||
}
|
||||
|
||||
await PumpOnceAsync(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"never received {typeof(T).Name}");
|
||||
}
|
||||
|
||||
public void ClearInbox()
|
||||
{
|
||||
_inbox.Clear();
|
||||
_arrival.Clear();
|
||||
}
|
||||
|
||||
private async Task PumpOnceAsync(TimeSpan wait)
|
||||
{
|
||||
var buffer = new byte[1500];
|
||||
var received = await ReceiveAsync(buffer, wait);
|
||||
|
||||
if (received > 0)
|
||||
{
|
||||
_kcp!.Input(buffer.AsSpan(0, received));
|
||||
|
||||
while (_kcp.TryReceive(out var stream))
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (NetPacket.TryRead(stream.AsSpan(offset), out var packet, out var consumed))
|
||||
{
|
||||
_inbox.Add(packet);
|
||||
_arrival.Add(packet.Name);
|
||||
offset += consumed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_clock += 20;
|
||||
_kcp!.Update(_clock);
|
||||
}
|
||||
|
||||
private async Task<int> ReceiveAsync(byte[] buffer, TimeSpan timeout)
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _socket.ReceiveFromAsync(
|
||||
buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellation.Token);
|
||||
|
||||
return result.ReceivedBytes;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _socket.Dispose();
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// A beta dispatch stops answering once its window closes, so a client that updated past
|
||||
// everything in config/versions.json has to be served from the closest build on hand.
|
||||
public class GameVersionTests
|
||||
{
|
||||
private static readonly string[] Cached =
|
||||
[
|
||||
"OSBETAWin4.4.51",
|
||||
"CNBETAWin4.4.53",
|
||||
"CNBETAWin4.5.51",
|
||||
"CNBETAWin4.5.52",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void VersionsParseIntoAChannelAndNumbers()
|
||||
{
|
||||
Assert.True(GameVersion.TryParse("CNBETAWin4.5.53", out var version));
|
||||
Assert.Equal("CNBETAWin", version.Channel);
|
||||
Assert.Equal([4, 5, 53], version.Parts);
|
||||
|
||||
Assert.False(GameVersion.TryParse("nonsense", out _));
|
||||
Assert.False(GameVersion.TryParse("CNBETAWin4.5.x", out _));
|
||||
Assert.False(GameVersion.TryParse("", out _));
|
||||
Assert.False(GameVersion.TryParse(null, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CNBETAWin4.5.52", "CNBETAWin4.5.53", -1)]
|
||||
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.52", 1)]
|
||||
[InlineData("CNBETAWin4.5.53", "CNBETAWin4.5.53", 0)]
|
||||
[InlineData("CNBETAWin4.9.0", "CNBETAWin4.10.0", -1)]
|
||||
// a missing trailing part counts as zero, so 4.5 and 4.5.0 are the same build
|
||||
[InlineData("CNBETAWin4.5", "CNBETAWin4.5.0", 0)]
|
||||
public void VersionsCompareNumerically(string left, string right, int expected)
|
||||
{
|
||||
Assert.True(GameVersion.TryParse(left, out var a));
|
||||
Assert.True(GameVersion.TryParse(right, out var b));
|
||||
Assert.Equal(expected, Math.Sign(a.CompareTo(b)));
|
||||
}
|
||||
|
||||
// the case that matters: the client updated past anything the dispatch will still serve
|
||||
[Fact]
|
||||
public void ANewerClientBorrowsTheClosestOlderBuild()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.53", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.52", nearest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelsNeverBorrowFromEachOther()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "OSBETAWin4.9.99", out var os));
|
||||
Assert.Equal("OSBETAWin4.4.51", os);
|
||||
|
||||
Assert.False(GameVersion.TryFindNearest(Cached, "CNPRODWin4.5.53", out _));
|
||||
Assert.False(GameVersion.TryFindNearest(Cached, "nonsense", out _));
|
||||
Assert.False(GameVersion.TryFindNearest([], "CNBETAWin4.5.53", out _));
|
||||
}
|
||||
|
||||
// nothing older to fall back on, so the newest known build beats having no urls at all
|
||||
[Fact]
|
||||
public void AnOlderClientStillGetsUrls()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin3.0.0", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.52", nearest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExactMatchIsPreferredOverEverythingElse()
|
||||
{
|
||||
Assert.True(GameVersion.TryFindNearest(Cached, "CNBETAWin4.5.51", out var nearest));
|
||||
Assert.Equal("CNBETAWin4.5.51", nearest);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using CapySR.Kcp;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// two kcp endpoints wired together over a virtual clock, with optional loss/reorder
|
||||
public sealed class KcpLink
|
||||
{
|
||||
private readonly List<byte[]> _towardsServer = [];
|
||||
private readonly List<byte[]> _towardsClient = [];
|
||||
private readonly Random _random;
|
||||
|
||||
public KcpLink(uint conversation = 1, uint token = 0xC0FFEE, int seed = 1234)
|
||||
{
|
||||
_random = new Random(seed);
|
||||
|
||||
Client = new Kcp.Kcp(conversation, token, data => _towardsServer.Add(data.ToArray()));
|
||||
Server = new Kcp.Kcp(conversation, token, data => _towardsClient.Add(data.ToArray()));
|
||||
|
||||
foreach (var kcp in new[] { Client, Server })
|
||||
{
|
||||
kcp.SetNoDelay(true, 10, 2, true);
|
||||
kcp.SetWindowSize(256, 256);
|
||||
}
|
||||
}
|
||||
|
||||
public Kcp.Kcp Client { get; }
|
||||
|
||||
public Kcp.Kcp Server { get; }
|
||||
|
||||
public double LossRate { get; set; }
|
||||
|
||||
public bool Reorder { get; set; }
|
||||
|
||||
public uint Clock { get; private set; }
|
||||
|
||||
public int DroppedDatagrams { get; private set; }
|
||||
|
||||
public void Pump(int steps, int stepMs = 10)
|
||||
{
|
||||
for (var i = 0; i < steps; i++)
|
||||
{
|
||||
Clock += (uint)stepMs;
|
||||
Client.Update(Clock);
|
||||
Server.Update(Clock);
|
||||
|
||||
Deliver(_towardsServer, Server);
|
||||
Deliver(_towardsClient, Client);
|
||||
}
|
||||
}
|
||||
|
||||
private void Deliver(List<byte[]> queue, Kcp.Kcp destination)
|
||||
{
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var batch = queue.ToList();
|
||||
queue.Clear();
|
||||
|
||||
if (Reorder && batch.Count > 1)
|
||||
{
|
||||
batch.Reverse();
|
||||
}
|
||||
|
||||
foreach (var datagram in batch)
|
||||
{
|
||||
if (LossRate > 0 && _random.NextDouble() < LossRate)
|
||||
{
|
||||
DroppedDatagrams++;
|
||||
continue;
|
||||
}
|
||||
|
||||
destination.Input(datagram);
|
||||
}
|
||||
}
|
||||
|
||||
public List<byte[]> DrainServer() => Drain(Server);
|
||||
|
||||
public List<byte[]> DrainClient() => Drain(Client);
|
||||
|
||||
private static List<byte[]> Drain(Kcp.Kcp kcp)
|
||||
{
|
||||
var received = new List<byte[]>();
|
||||
|
||||
while (kcp.TryReceive(out var data))
|
||||
{
|
||||
received.Add(data);
|
||||
}
|
||||
|
||||
return received;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class KcpTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeliversSmallMessage()
|
||||
{
|
||||
var link = new KcpLink();
|
||||
var payload = "hello capybara"u8.ToArray();
|
||||
|
||||
link.Client.Send(payload);
|
||||
link.Pump(20);
|
||||
|
||||
var received = link.DrainServer();
|
||||
Assert.Single(received);
|
||||
Assert.Equal(payload, received[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReassemblesFragmentedMessage()
|
||||
{
|
||||
var link = new KcpLink();
|
||||
var payload = RandomBytes(100_000, seed: 7);
|
||||
|
||||
link.Client.Send(payload);
|
||||
link.Pump(400);
|
||||
|
||||
var received = link.DrainServer();
|
||||
Assert.Single(received);
|
||||
Assert.Equal(payload, received[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesOrderAcrossManyMessages()
|
||||
{
|
||||
var link = new KcpLink();
|
||||
var sent = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var payload = RandomBytes(300 + (i * 37), seed: i);
|
||||
sent.Add(payload);
|
||||
link.Client.Send(payload);
|
||||
}
|
||||
|
||||
link.Pump(400);
|
||||
|
||||
var received = link.DrainServer();
|
||||
Assert.Equal(sent.Count, received.Count);
|
||||
|
||||
for (var i = 0; i < sent.Count; i++)
|
||||
{
|
||||
Assert.Equal(sent[i], received[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoversFromPacketLoss()
|
||||
{
|
||||
var link = new KcpLink { LossRate = 0.25 };
|
||||
var payload = RandomBytes(40_000, seed: 11);
|
||||
|
||||
link.Client.Send(payload);
|
||||
link.Pump(2000);
|
||||
|
||||
var received = link.DrainServer();
|
||||
Assert.True(link.DroppedDatagrams > 0, "expected the link to actually drop something");
|
||||
Assert.Single(received);
|
||||
Assert.Equal(payload, received[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoversFromReordering()
|
||||
{
|
||||
var link = new KcpLink { Reorder = true };
|
||||
var payload = RandomBytes(40_000, seed: 13);
|
||||
|
||||
link.Client.Send(payload);
|
||||
link.Pump(1000);
|
||||
|
||||
var received = link.DrainServer();
|
||||
Assert.Single(received);
|
||||
Assert.Equal(payload, received[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarriesTrafficBothWays()
|
||||
{
|
||||
var link = new KcpLink { LossRate = 0.1 };
|
||||
var request = RandomBytes(5_000, seed: 17);
|
||||
var response = RandomBytes(9_000, seed: 19);
|
||||
|
||||
link.Client.Send(request);
|
||||
link.Server.Send(response);
|
||||
link.Pump(1000);
|
||||
|
||||
Assert.Equal(request, Assert.Single(link.DrainServer()));
|
||||
Assert.Equal(response, Assert.Single(link.DrainClient()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsConversationFromDatagram()
|
||||
{
|
||||
var captured = Array.Empty<byte>();
|
||||
var kcp = new Kcp.Kcp(0xABCDEF, 0x1234, data => captured = data.ToArray());
|
||||
|
||||
kcp.Update(0);
|
||||
kcp.Send("x"u8.ToArray());
|
||||
kcp.Update(100);
|
||||
|
||||
Assert.NotEmpty(captured);
|
||||
Assert.Equal(0xABCDEFu, Kcp.Kcp.GetConversation(captured));
|
||||
}
|
||||
|
||||
private static byte[] RandomBytes(int length, int seed)
|
||||
{
|
||||
var buffer = new byte[length];
|
||||
new Random(seed).NextBytes(buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
using CapySR.GameServer.Game;
|
||||
using CapySR.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class LineupTests
|
||||
{
|
||||
private static Lineup Team(params uint[] ids)
|
||||
{
|
||||
var lineup = new Lineup();
|
||||
lineup.Fill(ids);
|
||||
return lineup;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillTakesTheFirstFour()
|
||||
{
|
||||
var lineup = Team(1001, 1002, 1003, 1004, 1005);
|
||||
|
||||
Assert.Equal([1001u, 1002u, 1003u, 1004u], lineup.Slots);
|
||||
Assert.Equal(4, lineup.Count);
|
||||
Assert.Equal(1001u, lineup.LeaderAvatarId);
|
||||
Assert.Equal(0u, lineup.LeaderSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinPlacesIntoASlot()
|
||||
{
|
||||
var lineup = Team(1001);
|
||||
|
||||
Assert.Equal(Retcode.RetSucc, lineup.Join(1002, 2));
|
||||
Assert.Equal(1002u, lineup.Slots[2]);
|
||||
Assert.Equal(2, lineup.Count);
|
||||
Assert.Equal([1001u, 1002u], lineup.AvatarIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoiningTwiceMovesRatherThanDuplicates()
|
||||
{
|
||||
var lineup = Team(1001, 1002, 1003);
|
||||
|
||||
Assert.Equal(Retcode.RetSucc, lineup.Join(1003, 0));
|
||||
|
||||
Assert.Equal(1, lineup.Slots.Count(id => id == 1003));
|
||||
Assert.Equal(1003u, lineup.Slots[0]);
|
||||
// whatever was in slot 0 moved to where 1003 came from
|
||||
Assert.Equal(1001u, lineup.Slots[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoiningTheSameSlotIsRejected()
|
||||
{
|
||||
var lineup = Team(1001, 1002);
|
||||
|
||||
Assert.Equal(Retcode.RetLineupAvatarAlreadyIn, lineup.Join(1002, 1));
|
||||
Assert.Equal(Retcode.RetLineupInvalidMemberPos, lineup.Join(1003, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuitRemovesButNeverEmptiesTheTeam()
|
||||
{
|
||||
var lineup = Team(1001, 1002);
|
||||
|
||||
Assert.Equal(Retcode.RetSucc, lineup.Quit(1002));
|
||||
Assert.Equal(1, lineup.Count);
|
||||
|
||||
Assert.Equal(Retcode.RetLineupOnlyOneMember, lineup.Quit(1001));
|
||||
Assert.Equal(1, lineup.Count);
|
||||
|
||||
Assert.Equal(Retcode.RetLineupAvatarNotExist, lineup.Quit(1009));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwapExchangesSlotsAndTheLeaderFollows()
|
||||
{
|
||||
var lineup = Team(1001, 1002, 1003, 1004);
|
||||
|
||||
Assert.Equal(Retcode.RetSucc, lineup.Swap(0, 3));
|
||||
|
||||
Assert.Equal(1004u, lineup.Slots[0]);
|
||||
Assert.Equal(1001u, lineup.Slots[3]);
|
||||
Assert.Equal(1001u, lineup.LeaderAvatarId);
|
||||
Assert.Equal(3u, lineup.LeaderSlot);
|
||||
Assert.Equal(Retcode.RetLineupSwapSameSlot, lineup.Swap(1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceSetsSlotsAndLeader()
|
||||
{
|
||||
var lineup = new Lineup();
|
||||
|
||||
lineup.Replace([(0u, 1005u), (2u, 1006u)], leaderSlot: 2);
|
||||
|
||||
Assert.Equal(1005u, lineup.Slots[0]);
|
||||
Assert.Equal(0u, lineup.Slots[1]);
|
||||
Assert.Equal(1006u, lineup.Slots[2]);
|
||||
Assert.Equal(1006u, lineup.LeaderAvatarId);
|
||||
Assert.Equal(2u, lineup.LeaderSlot);
|
||||
Assert.Equal(1u, lineup.LeaderIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceDropsDuplicates()
|
||||
{
|
||||
var lineup = new Lineup();
|
||||
|
||||
lineup.Replace([(0u, 1005u), (1u, 1005u), (2u, 1006u)], leaderSlot: 0);
|
||||
|
||||
Assert.Equal([1005u, 0u, 1006u, 0u], lineup.Slots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeaderMustPointAtSomeone()
|
||||
{
|
||||
var lineup = Team(1001, 1002);
|
||||
|
||||
Assert.Equal(Retcode.RetSucc, lineup.SetLeader(1));
|
||||
Assert.Equal(1002u, lineup.LeaderAvatarId);
|
||||
|
||||
// removing the leader moves it to whoever is left
|
||||
lineup.Quit(1002);
|
||||
Assert.Equal(1001u, lineup.LeaderAvatarId);
|
||||
Assert.Equal(0u, lineup.LeaderSlot);
|
||||
|
||||
Assert.Equal(Retcode.RetLineupNotValidLeader, lineup.SetLeader(3));
|
||||
Assert.Equal(Retcode.RetLineupNotValidLeader, lineup.SetLeader(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceWithAHoleKeepsTheLeaderValid()
|
||||
{
|
||||
var lineup = new Lineup();
|
||||
|
||||
lineup.Replace([(1u, 1007u)], leaderSlot: 0);
|
||||
|
||||
Assert.Equal(1007u, lineup.LeaderAvatarId);
|
||||
Assert.Equal(1u, lineup.LeaderSlot);
|
||||
Assert.Equal(1007u, lineup.Slots[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OccupiedSkipsHoles()
|
||||
{
|
||||
var lineup = new Lineup();
|
||||
lineup.Replace([(0u, 1001u), (3u, 1004u)], leaderSlot: 0);
|
||||
|
||||
Assert.Equal([(0u, 1001u), (3u, 1004u)], lineup.Occupied);
|
||||
Assert.Equal([1001u, 1004u], lineup.AvatarIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PruneDropsWhatIsNoLongerOwned()
|
||||
{
|
||||
var lineup = Team(1001, 1002, 1003);
|
||||
lineup.SetLeader(2);
|
||||
|
||||
Assert.True(lineup.Prune(id => id != 1003));
|
||||
Assert.Equal([1001u, 1002u, 0u, 0u], lineup.Slots);
|
||||
Assert.Equal(1001u, lineup.LeaderAvatarId);
|
||||
Assert.False(lineup.Prune(_ => true));
|
||||
}
|
||||
}
|
||||
|
||||
public class LineupBookTests
|
||||
{
|
||||
[Fact]
|
||||
public void SeedFillsOnlyTheFirstSquadOnce()
|
||||
{
|
||||
var book = new LineupBook();
|
||||
book.Seed([1001, 1002, 1003, 1004, 1005]);
|
||||
|
||||
Assert.Equal([1001u, 1002u, 1003u, 1004u], book.Squad(0).Slots);
|
||||
Assert.True(book.Squad(1).IsEmpty);
|
||||
|
||||
book.Seed([1009]);
|
||||
Assert.Equal(1001u, book.Squad(0).Slots[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchingNeedsANonEmptySquad()
|
||||
{
|
||||
var book = new LineupBook();
|
||||
book.Seed([1001, 1002]);
|
||||
|
||||
Assert.Equal(Retcode.RetLineupIsEmpty, book.SetCurrent(1));
|
||||
Assert.Equal(Retcode.RetLineupInvalidIndex, book.SetCurrent(9));
|
||||
|
||||
book.Squad(1).Fill([1003]);
|
||||
Assert.Equal(Retcode.RetSucc, book.SetCurrent(1));
|
||||
Assert.Same(book.Squad(1), book.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtraLineupsTakeOverWhileActive()
|
||||
{
|
||||
var book = new LineupBook();
|
||||
book.Seed([1001, 1002]);
|
||||
|
||||
var challenge = book.SetExtra(ExtraLineupType.LineupChallenge, [1003, 1004]);
|
||||
|
||||
Assert.True(book.InExtraLineup);
|
||||
Assert.Same(challenge, book.Current);
|
||||
Assert.Equal([1003u, 1004u], book.Current.AvatarIds);
|
||||
|
||||
book.ClearExtra();
|
||||
Assert.False(book.InExtraLineup);
|
||||
Assert.Equal([1001u, 1002u], book.Current.AvatarIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TechniquePointsAreSharedAndCapped()
|
||||
{
|
||||
var book = new LineupBook();
|
||||
book.Seed([1001, 1002]);
|
||||
|
||||
Assert.Equal(LineupBook.BaseMaxMp, book.MaxMp);
|
||||
Assert.True(book.SpendMp());
|
||||
Assert.Equal(LineupBook.BaseMaxMp - 1, book.Mp);
|
||||
|
||||
book.GainMp(10);
|
||||
Assert.Equal(LineupBook.BaseMaxMp, book.Mp);
|
||||
|
||||
// Phainon raises the cap while he is in the team
|
||||
book.Squad(0).Join(1408, 2);
|
||||
Assert.Equal(LineupBook.BaseMaxMp + 3, book.MaxMp);
|
||||
|
||||
book.RefillMp();
|
||||
Assert.Equal(LineupBook.BaseMaxMp + 3, book.Mp);
|
||||
|
||||
book.Squad(0).Quit(1408);
|
||||
Assert.Equal(LineupBook.BaseMaxMp, book.Mp);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using CapySR.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
public class ProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegistryResolvesEveryCmdId()
|
||||
{
|
||||
ProtocolRegistry.EnsureInitialized();
|
||||
Assert.True(ProtocolRegistry.Count > 2000);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(34, "PlayerLoginCsReq")]
|
||||
[InlineData(60, "PlayerGetTokenCsReq")]
|
||||
[InlineData(134, "PVEBattleResultCsReq")]
|
||||
[InlineData(125, "PVEBattleResultScRsp")]
|
||||
public void CmdIdsMatchTheDump(ushort id, string name)
|
||||
{
|
||||
Assert.Equal(name, ProtocolRegistry.GetName(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CmdIdLookupIsTwoWay()
|
||||
{
|
||||
Assert.Equal(34, ProtocolRegistry.GetCmdId<PlayerLoginCsReq>());
|
||||
Assert.Equal(34u, (uint)(ProtocolRegistry.GetCmdId(new PlayerLoginCsReq()) ?? 0));
|
||||
Assert.Equal((ushort)CmdId.PlayerLoginCsReq, ProtocolRegistry.GetCmdId<PlayerLoginCsReq>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PacketRoundTrips()
|
||||
{
|
||||
var login = new PlayerLoginScRsp
|
||||
{
|
||||
LoginRandom = 12345,
|
||||
Stamina = 240,
|
||||
BasicInfo = new PlayerBasicInfo { Nickname = "Capy", Level = 70, WorldLevel = 6 },
|
||||
};
|
||||
|
||||
var encoded = NetPacket.Create(login).ToArray();
|
||||
|
||||
Assert.True(NetPacket.TryRead(encoded, out var packet, out var consumed));
|
||||
Assert.Equal(encoded.Length, consumed);
|
||||
Assert.Equal(ProtocolRegistry.GetCmdId<PlayerLoginScRsp>(), packet.CmdId);
|
||||
|
||||
var decoded = packet.ParseBody<PlayerLoginScRsp>();
|
||||
Assert.Equal(12345u, decoded.LoginRandom);
|
||||
Assert.Equal("Capy", decoded.BasicInfo.Nickname);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsBackToBackPackets()
|
||||
{
|
||||
var first = NetPacket.Create(new PlayerHeartBeatScRsp { ClientTimeMs = 1 }).ToArray();
|
||||
var second = NetPacket.Empty(ProtocolRegistry.GetCmdId<GetBagScRsp>()).ToArray();
|
||||
|
||||
var stream = new byte[first.Length + second.Length];
|
||||
first.CopyTo(stream, 0);
|
||||
second.CopyTo(stream, first.Length);
|
||||
|
||||
Assert.True(NetPacket.TryRead(stream, out var a, out var consumedA));
|
||||
Assert.True(NetPacket.TryRead(stream.AsSpan(consumedA), out var b, out _));
|
||||
|
||||
Assert.Equal(ProtocolRegistry.GetCmdId<PlayerHeartBeatScRsp>(), a.CmdId);
|
||||
Assert.Equal(ProtocolRegistry.GetCmdId<GetBagScRsp>(), b.CmdId);
|
||||
Assert.Empty(b.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsMalformedPackets()
|
||||
{
|
||||
Assert.False(NetPacket.TryRead([1, 2, 3], out _, out _));
|
||||
|
||||
var good = NetPacket.Create(new PlayerHeartBeatScRsp()).ToArray();
|
||||
|
||||
var badHead = good.ToArray();
|
||||
badHead[0] ^= 0xFF;
|
||||
Assert.False(NetPacket.TryRead(badHead, out _, out _));
|
||||
|
||||
var badTail = good.ToArray();
|
||||
badTail[^1] ^= 0xFF;
|
||||
Assert.False(NetPacket.TryRead(badTail, out _, out _));
|
||||
|
||||
Assert.False(NetPacket.TryRead(good.AsSpan(0, good.Length - 1), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandshakeOperationRoundTrips()
|
||||
{
|
||||
var accept = NetOperation.Accept(conversation: 7, token: 0xDEAD, echo: 99);
|
||||
|
||||
Assert.True(NetOperation.TryRead(accept.ToArray(), out var parsed));
|
||||
Assert.Equal(accept, parsed);
|
||||
Assert.Equal(NetOperation.Size, accept.ToArray().Length);
|
||||
|
||||
var connect = new NetOperation(
|
||||
NetOperation.ConnectHead, 0, 0, 1234, NetOperation.ConnectTail);
|
||||
|
||||
Assert.True(connect.IsConnect);
|
||||
Assert.False(connect.IsDisconnect);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.Data;
|
||||
using CapySR.Data.SrTools;
|
||||
using CapySR.GameServer.Battle;
|
||||
using CapySR.GameServer.Game;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// runs against a real freesr-data.json when one is present
|
||||
public class SrToolsTests(ITestOutputHelper output)
|
||||
{
|
||||
private static SrToolsData? Load(ITestOutputHelper output)
|
||||
{
|
||||
var path = SrToolsData.ResolvePath(RepoPaths.Root);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
output.WriteLine($"skipping: no {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
output.WriteLine($"loaded {path}");
|
||||
return SrToolsData.Load(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesRealExport()
|
||||
{
|
||||
var player = Load(output);
|
||||
if (player is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
output.WriteLine($"{player.Avatars.Count} avatars, {player.Relics.Count} relics, " +
|
||||
$"{player.Lightcones.Count} lightcones, stage {player.BattleConfig.StageId}");
|
||||
|
||||
Assert.NotEmpty(player.Avatars);
|
||||
Assert.NotEmpty(player.Relics);
|
||||
Assert.NotEmpty(player.Lightcones);
|
||||
|
||||
// dictionary keys arrive as json strings
|
||||
Assert.All(player.Avatars, kv => Assert.Equal(kv.Key, kv.Value.AvatarId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadsTechniquesNotUseTechnique()
|
||||
{
|
||||
var player = Load(output);
|
||||
if (player is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var withTechniques = player.Avatars.Values.Count(a => a.Techniques.Count > 0);
|
||||
output.WriteLine($"{withTechniques}/{player.Avatars.Count} avatars carry technique buffs");
|
||||
|
||||
Assert.True(withTechniques > player.Avatars.Count / 2,
|
||||
$"only {withTechniques} avatars had techniques - is the json field name still 'techniques'?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TracesAndGearSurvive()
|
||||
{
|
||||
var player = Load(output);
|
||||
if (player is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.All(player.Avatars.Values, a => Assert.NotEmpty(a.Data.Skills));
|
||||
Assert.Contains(player.Avatars.Values, a => a.Data.Rank > 0);
|
||||
|
||||
// relic and lightcone unique ids must not collide
|
||||
var relicIds = player.Relics.Select(r => r.UniqueId).ToHashSet();
|
||||
var lightconeIds = player.Lightcones.Select(l => l.UniqueId).ToHashSet();
|
||||
Assert.Empty(relicIds.Intersect(lightconeIds));
|
||||
|
||||
Assert.Contains(player.Relics, r => r.EquipAvatar != 0 && r.SubAffixes.Count > 0);
|
||||
Assert.Contains(player.Lightcones, l => l.EquipAvatar != 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildsABattleFromTheRealExport()
|
||||
{
|
||||
var player = Load(output);
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
|
||||
if (player is null || sources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = GameData.Load(sources);
|
||||
var config = player.BattleConfig;
|
||||
|
||||
var lineup = player.Avatars.Keys.Where(Roster.IsPlayable).OrderBy(id => id).Take(4).ToList();
|
||||
|
||||
var battle = new BattleBuilder(data).Build(
|
||||
new BattleRequest
|
||||
{
|
||||
StageId = config.StageId,
|
||||
Lineup = lineup,
|
||||
Kind = config.Kind,
|
||||
CycleCount = config.CycleCount == 0 ? 30 : config.CycleCount,
|
||||
MonsterWaves = config.Monsters is { Count: > 0 } ? config.Monsters : null,
|
||||
Blessings = config.Blessings,
|
||||
},
|
||||
player);
|
||||
|
||||
output.WriteLine($"stage {battle.StageId}: {battle.BattleAvatarList.Count} avatars, " +
|
||||
$"{battle.BuffList.Count} buffs, {battle.MonsterWaveList.Count} waves");
|
||||
|
||||
Assert.Equal(lineup.Count, battle.BattleAvatarList.Count);
|
||||
Assert.All(battle.BattleAvatarList, a => Assert.NotEmpty(a.SkilltreeList));
|
||||
Assert.NotEmpty(battle.MonsterWaveList);
|
||||
|
||||
// srtools' own technique ids must reach the battle
|
||||
foreach (var avatarId in lineup)
|
||||
{
|
||||
foreach (var technique in player.Avatars[avatarId].Techniques)
|
||||
{
|
||||
Assert.Contains(battle.BuffList, b => b.Id == technique);
|
||||
}
|
||||
}
|
||||
|
||||
// and its blessings
|
||||
foreach (var blessing in config.Blessings)
|
||||
{
|
||||
Assert.Contains(battle.BuffList, b => b.Id == blessing.Id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MonsterAmountIsExpanded()
|
||||
{
|
||||
var sources = new DataConfig().ResolvedSources.Where(Directory.Exists).ToList();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = GameData.Load(sources);
|
||||
var player = new SrToolsData();
|
||||
player.Avatars[1001] = new SrAvatar { AvatarId = 1001 };
|
||||
|
||||
var battle = new BattleBuilder(data).Build(
|
||||
new BattleRequest
|
||||
{
|
||||
StageId = 1022010,
|
||||
Lineup = [1001],
|
||||
MonsterWaves = [[new SrMonster { MonsterId = 3014022, Level = 95, Amount = 3 }]],
|
||||
},
|
||||
player);
|
||||
|
||||
Assert.Equal(3, battle.MonsterWaveList[0].MonsterList.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public class InventoryIdTests(ITestOutputHelper output)
|
||||
{
|
||||
// a unique_id of 0 means "no item"; sending one makes the client's inventory
|
||||
// red-dot filter NPE every frame and the character screen freezes black
|
||||
[Fact]
|
||||
public void NoInventoryItemUsesIdZero()
|
||||
{
|
||||
var path = SrToolsData.ResolvePath(RepoPaths.Root);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = SrToolsData.Load(path);
|
||||
|
||||
Assert.All(player.Relics, r => Assert.NotEqual(0u, r.UniqueId));
|
||||
Assert.All(player.Lightcones, l => Assert.NotEqual(0u, l.UniqueId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelicAndLightconeIdRangesDoNotOverlap()
|
||||
{
|
||||
var path = SrToolsData.ResolvePath(RepoPaths.Root);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = SrToolsData.Load(path);
|
||||
|
||||
var relics = player.Relics.Select(r => r.UniqueId).ToHashSet();
|
||||
var lightcones = player.Lightcones.Select(l => l.UniqueId).ToHashSet();
|
||||
|
||||
output.WriteLine($"relics {relics.Min()}-{relics.Max()}, lightcones {lightcones.Min()}-{lightcones.Max()}");
|
||||
Assert.Empty(relics.Intersect(lightcones));
|
||||
Assert.Equal(player.Relics.Count, relics.Count);
|
||||
Assert.Equal(player.Lightcones.Count, lightcones.Count);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using CapySR.Common;
|
||||
using CapySR.GameServer.Game;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace CapySR.Tests;
|
||||
|
||||
// The game data is a separate checkout, so a fresh clone has none. Tests that need it check
|
||||
// Available and return instead of failing. Building the world is deferred for the same
|
||||
// reason, and shared because loading the tables is the slow part of the suite.
|
||||
internal static class TestWorld
|
||||
{
|
||||
private static readonly Lazy<GameWorld> Instance =
|
||||
new(() => new GameWorld(new ServerConfig(), NullLogger.Instance));
|
||||
|
||||
public static bool Available { get; } = new DataConfig().ResolvedSources.Any(Directory.Exists);
|
||||
|
||||
public static GameWorld Current => Instance.Value;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,155 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CapySR.ProtoGen;
|
||||
|
||||
// the dump's translation table isn't injective: 11 real names are claimed by two classes each.
|
||||
// one keeps the name, the rest fall back to their obf name. picking wrong means answering on the
|
||||
// wrong cmdid, so score it against independent signals instead of guessing.
|
||||
public sealed class DuplicateResolver
|
||||
{
|
||||
// deliberate calls, keyed by name. add one when the client proves which cmdid is real.
|
||||
private static readonly Dictionary<string, ushort> Overrides = new(StringComparer.Ordinal)
|
||||
{
|
||||
// 321 sits in CmdAvatarType next to DressRelicAvatarCsReq(364); rival 4515 is CmdPlayerReturnType
|
||||
["DressRelicAvatarScRsp"] = 321,
|
||||
|
||||
// UNVERIFIED: 716 { retcode=6 } vs 719 { unk=7, retcode=9 }, both CmdLineupType.
|
||||
// we only ever answer retcode 0, which proto3 omits, so both encode empty. flip if joining hangs.
|
||||
["JoinLineupScRsp"] = 716,
|
||||
};
|
||||
|
||||
private static readonly Regex ServiceEnumPattern = new(@"^enum\s+Cmd(?<service>\w+)Type\s*\{", RegexOptions.Compiled);
|
||||
private static readonly Regex EnumMemberPattern = new(@"=\s*(?<id>\d+)\s*;", RegexOptions.Compiled);
|
||||
|
||||
private readonly Dictionary<ushort, List<string>> _services = [];
|
||||
private readonly HashSet<(ushort Id, string Name)> _packetIds = [];
|
||||
|
||||
public DuplicateResolver(List<ProtoDeclaration> declarations, string packetIdsPath)
|
||||
{
|
||||
foreach (var declaration in declarations.Where(d => d.Kind == DeclarationKind.Enum))
|
||||
{
|
||||
var match = ServiceEnumPattern.Match(declaration.Body[0]);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var service = match.Groups["service"].Value;
|
||||
|
||||
foreach (var line in declaration.Body.Skip(1))
|
||||
{
|
||||
var member = EnumMemberPattern.Match(line);
|
||||
if (member.Success && ushort.TryParse(member.Groups["id"].Value, out var id) && id != 0)
|
||||
{
|
||||
if (!_services.TryGetValue(id, out var list))
|
||||
{
|
||||
_services[id] = list = [];
|
||||
}
|
||||
|
||||
list.Add(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in PacketIdFile.Read(packetIdsPath))
|
||||
{
|
||||
_packetIds.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public ProtoDeclaration PickWinner(List<ProtoDeclaration> candidates) =>
|
||||
candidates.OrderByDescending(Score).ThenBy(c => c.CmdId ?? ushort.MaxValue).First();
|
||||
|
||||
public string Explain(ProtoDeclaration declaration)
|
||||
{
|
||||
var services = declaration.CmdId is { } id && _services.TryGetValue(id, out var list)
|
||||
? string.Join('/', list)
|
||||
: "-";
|
||||
|
||||
return $"CmdID={declaration.CmdId?.ToString() ?? "?"} Type={declaration.PacketType ?? "?"} " +
|
||||
$"service={services} score={Score(declaration)}";
|
||||
}
|
||||
|
||||
private int Score(ProtoDeclaration declaration)
|
||||
{
|
||||
if (declaration.CmdId is not { } id)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (Overrides.TryGetValue(declaration.Name, out var chosen) && chosen == id)
|
||||
{
|
||||
score += 8;
|
||||
}
|
||||
|
||||
// a message in service "Avatar" should say Avatar in its name
|
||||
if (_services.TryGetValue(id, out var services) &&
|
||||
services.Any(s => declaration.Name.Contains(s, StringComparison.Ordinal)))
|
||||
{
|
||||
score += 4;
|
||||
}
|
||||
|
||||
if (SuffixMatchesType(declaration))
|
||||
{
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if (_packetIds.Contains((id, declaration.Name)))
|
||||
{
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool SuffixMatchesType(ProtoDeclaration declaration)
|
||||
{
|
||||
var expected = declaration.Name switch
|
||||
{
|
||||
var n when n.EndsWith("CsReq", StringComparison.Ordinal) => "Req",
|
||||
var n when n.EndsWith("ScRsp", StringComparison.Ordinal) => "Rsp",
|
||||
var n when n.EndsWith("Notify", StringComparison.Ordinal) => "Notify",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return expected is not null && declaration.PacketType == expected;
|
||||
}
|
||||
}
|
||||
|
||||
// "34 = PlayerLoginCsReq // obf: ..."
|
||||
public static class PacketIdFile
|
||||
{
|
||||
public static IEnumerable<(ushort Id, string Name)> Read(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var eq = trimmed.IndexOf('=');
|
||||
if (eq < 0 || !ushort.TryParse(trimmed[..eq].Trim(), out var id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rest = trimmed[(eq + 1)..];
|
||||
var comment = rest.IndexOf("//", StringComparison.Ordinal);
|
||||
var name = (comment >= 0 ? rest[..comment] : rest).Trim();
|
||||
|
||||
if (name.Length > 0)
|
||||
{
|
||||
yield return (id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using CapySR.ProtoGen;
|
||||
|
||||
// dumped proto -> deduped proto -> protoc --csharp_out -> cmdid tables.
|
||||
// dotnet run --project tools/CapySR.ProtoGen
|
||||
|
||||
var repoRoot = FindRepoRoot();
|
||||
if (repoRoot is null)
|
||||
{
|
||||
Console.Error.WriteLine("could not locate repo root (no Directory.Build.props found walking up)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceProto = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "StarRail.proto");
|
||||
var packetIds = Path.Combine(repoRoot, "stuff", "TurnBaseGameProto", "Raw", "packetIds.txt");
|
||||
var protocolProject = Path.Combine(repoRoot, "src", "CapySR.Protocol");
|
||||
var cleanedProtoDir = Path.Combine(protocolProject, "Proto");
|
||||
var cleanedProto = Path.Combine(cleanedProtoDir, "StarRail.proto");
|
||||
var generatedDir = Path.Combine(protocolProject, "Generated");
|
||||
|
||||
if (!File.Exists(sourceProto))
|
||||
{
|
||||
Console.Error.WriteLine($"source proto not found: {sourceProto}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(cleanedProtoDir);
|
||||
Directory.CreateDirectory(generatedDir);
|
||||
|
||||
Console.WriteLine($"reading {Path.GetRelativePath(repoRoot, sourceProto)}");
|
||||
var lines = File.ReadAllLines(sourceProto);
|
||||
var declarations = ProtoParser.Parse(lines);
|
||||
|
||||
var messages = declarations.Count(d => d.Kind == DeclarationKind.Message);
|
||||
var enums = declarations.Count(d => d.Kind == DeclarationKind.Enum);
|
||||
Console.WriteLine($"parsed {declarations.Count} top-level declarations ({messages} messages, {enums} enums)");
|
||||
|
||||
// -- 2. resolve duplicate names -------------------------------------------
|
||||
|
||||
var taken = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var declaration in declarations)
|
||||
{
|
||||
taken.Add(declaration.Name);
|
||||
}
|
||||
|
||||
var resolver = new DuplicateResolver(declarations, packetIds);
|
||||
var renames = 0;
|
||||
foreach (var group in declarations.GroupBy(d => d.Name, StringComparer.Ordinal).Where(g => g.Count() > 1).ToList())
|
||||
{
|
||||
var candidates = group.ToList();
|
||||
var winner = resolver.PickWinner(candidates);
|
||||
|
||||
Console.WriteLine($" duplicate '{group.Key}':");
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var marker = ReferenceEquals(candidate, winner) ? "keep" : " ";
|
||||
Console.WriteLine($" [{marker}] line {candidate.SourceLine,5} {resolver.Explain(candidate)}");
|
||||
}
|
||||
|
||||
foreach (var loser in candidates.Where(c => !ReferenceEquals(c, winner)))
|
||||
{
|
||||
var replacement = ChooseReplacementName(loser, taken);
|
||||
Console.WriteLine($" -> renamed to '{replacement}'");
|
||||
loser.Rename(replacement);
|
||||
taken.Add(replacement);
|
||||
renames++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"resolved {renames} duplicate name(s)");
|
||||
|
||||
// names the dump's translation got wrong, keyed by cmdid
|
||||
var knownRenames = new Dictionary<ushort, string>
|
||||
{
|
||||
// the dump has 5 lineup requests but calls 719 a second JoinLineupScRsp and has no
|
||||
// ReplaceLineupScRsp at all. pearl-sr's dump names it ReplaceLineupScRsp.
|
||||
[719] = "ReplaceLineupScRsp",
|
||||
};
|
||||
|
||||
foreach (var (cmdId, correct) in knownRenames)
|
||||
{
|
||||
var target = declarations.FirstOrDefault(d => d.CmdId == cmdId);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
Console.WriteLine($" warning: no message with CmdID {cmdId} to rename to '{correct}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (declarations.Any(d => d.Name == correct))
|
||||
{
|
||||
Console.WriteLine($" warning: '{correct}' already exists, leaving CmdID {cmdId} as '{target.Name}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" rename: CmdID {cmdId} '{target.Name}' -> '{correct}'");
|
||||
target.Rename(correct);
|
||||
taken.Add(correct);
|
||||
}
|
||||
|
||||
var stillDuplicated = declarations
|
||||
.GroupBy(d => d.Name, StringComparer.Ordinal)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
|
||||
if (stillDuplicated.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine($"unresolved duplicates remain: {string.Join(", ", stillDuplicated)}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// -- 3. emit the cleaned proto --------------------------------------------
|
||||
|
||||
var sb = new StringBuilder(6 * 1024 * 1024);
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("syntax = \"proto3\";");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("option csharp_namespace = \"CapySR.Protocol\";");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var declaration in declarations)
|
||||
{
|
||||
declaration.Write(sb);
|
||||
}
|
||||
|
||||
File.WriteAllText(cleanedProto, sb.ToString());
|
||||
Console.WriteLine($"wrote {Path.GetRelativePath(repoRoot, cleanedProto)} ({sb.Length / 1024} KiB)");
|
||||
|
||||
// -- 4. run protoc ---------------------------------------------------------
|
||||
|
||||
var protoc = ResolveProtoc(args);
|
||||
if (protoc is null)
|
||||
{
|
||||
Console.Error.WriteLine("protoc not found. Pass --protoc <path>, set PROTOC, or put it on PATH.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"running {protoc}");
|
||||
var protocResult = Run(protoc,
|
||||
[
|
||||
$"--csharp_out={generatedDir}",
|
||||
$"--proto_path={cleanedProtoDir}",
|
||||
"StarRail.proto",
|
||||
]);
|
||||
|
||||
if (protocResult.ExitCode != 0)
|
||||
{
|
||||
Console.Error.WriteLine("protoc failed:");
|
||||
Console.Error.WriteLine(protocResult.Output);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var generatedCs = Path.Combine(generatedDir, "StarRail.cs");
|
||||
if (File.Exists(generatedCs))
|
||||
{
|
||||
Console.WriteLine($"wrote {Path.GetRelativePath(repoRoot, generatedCs)} ({new FileInfo(generatedCs).Length / 1024} KiB)");
|
||||
}
|
||||
|
||||
// -- 5. emit the CmdId tables ---------------------------------------------
|
||||
|
||||
var withCmdId = declarations
|
||||
.Where(d => d.Kind == DeclarationKind.Message && d.CmdId is > 0)
|
||||
.OrderBy(d => d.CmdId!.Value)
|
||||
.ToList();
|
||||
|
||||
var byId = new Dictionary<ushort, ProtoDeclaration>();
|
||||
foreach (var declaration in withCmdId)
|
||||
{
|
||||
if (!byId.TryAdd(declaration.CmdId!.Value, declaration))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" warning: CmdID {declaration.CmdId} claimed by both '{byId[declaration.CmdId!.Value].Name}' " +
|
||||
$"and '{declaration.Name}' - keeping the first");
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = byId.OrderBy(kv => kv.Key).ToList();
|
||||
Console.WriteLine($"found {ordered.Count} messages carrying a CmdID");
|
||||
|
||||
CrossCheckPacketIds(packetIds, byId);
|
||||
|
||||
WriteCmdIdEnum(Path.Combine(generatedDir, "CmdId.g.cs"), ordered);
|
||||
WriteCmdIdTable(Path.Combine(generatedDir, "CmdIdTable.g.cs"), ordered);
|
||||
|
||||
Console.WriteLine("done");
|
||||
return 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static string ChooseReplacementName(ProtoDeclaration declaration, HashSet<string> taken)
|
||||
{
|
||||
if (declaration.ObfuscatedName is { Length: > 0 } obf && !taken.Contains(obf))
|
||||
{
|
||||
return obf;
|
||||
}
|
||||
|
||||
for (var i = 2; ; i++)
|
||||
{
|
||||
var candidate = $"{declaration.Name}_Dup{i}";
|
||||
if (!taken.Contains(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void CrossCheckPacketIds(string path, Dictionary<ushort, ProtoDeclaration> byId)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.WriteLine(" (packetIds.txt not found, skipping cross-check)");
|
||||
return;
|
||||
}
|
||||
|
||||
int agree = 0, disagree = 0, missing = 0;
|
||||
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var eq = trimmed.IndexOf('=');
|
||||
if (eq < 0 || !ushort.TryParse(trimmed[..eq].Trim(), out var id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rest = trimmed[(eq + 1)..];
|
||||
var comment = rest.IndexOf("//", StringComparison.Ordinal);
|
||||
var name = (comment >= 0 ? rest[..comment] : rest).Trim();
|
||||
|
||||
if (!byId.TryGetValue(id, out var declaration))
|
||||
{
|
||||
missing++;
|
||||
}
|
||||
else if (string.Equals(declaration.Name, name, StringComparison.Ordinal))
|
||||
{
|
||||
agree++;
|
||||
}
|
||||
else
|
||||
{
|
||||
disagree++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($" packetIds.txt cross-check: {agree} agree, {disagree} disagree, {missing} not in proto");
|
||||
}
|
||||
|
||||
static void WriteCmdIdEnum(string path, List<KeyValuePair<ushort, ProtoDeclaration>> ordered)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace CapySR.Protocol;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("public enum CmdId : ushort");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" None = 0,");
|
||||
|
||||
foreach (var (id, declaration) in ordered)
|
||||
{
|
||||
sb.AppendLine($" {declaration.Name} = {id},");
|
||||
}
|
||||
|
||||
sb.AppendLine("}");
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
Console.WriteLine($"wrote {Path.GetFileName(path)} ({ordered.Count} entries)");
|
||||
}
|
||||
|
||||
static void WriteCmdIdTable(string path, List<KeyValuePair<ushort, ProtoDeclaration>> ordered)
|
||||
{
|
||||
// parallel arrays; parsers get resolved from the descriptor at runtime
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// generated by tools/CapySR.ProtoGen - do not edit");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace CapySR.Protocol;");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("public static class CmdIdTable");
|
||||
sb.AppendLine("{");
|
||||
|
||||
sb.AppendLine(" public static ReadOnlySpan<ushort> Ids =>");
|
||||
sb.AppendLine(" [");
|
||||
AppendWrapped(sb, ordered.Select(kv => kv.Key.ToString()));
|
||||
sb.AppendLine(" ];");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(" public static readonly string[] Names =");
|
||||
sb.AppendLine(" [");
|
||||
AppendWrapped(sb, ordered.Select(kv => $"\"{kv.Value.Name}\""));
|
||||
sb.AppendLine(" ];");
|
||||
|
||||
sb.AppendLine("}");
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
Console.WriteLine($"wrote {Path.GetFileName(path)}");
|
||||
}
|
||||
|
||||
static void AppendWrapped(StringBuilder sb, IEnumerable<string> values)
|
||||
{
|
||||
var line = new StringBuilder(" ");
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (line.Length + value.Length + 2 > 116)
|
||||
{
|
||||
sb.AppendLine(line.ToString());
|
||||
line.Clear().Append(" ");
|
||||
}
|
||||
|
||||
line.Append(value).Append(", ");
|
||||
}
|
||||
|
||||
if (line.ToString().Trim().Length > 0)
|
||||
{
|
||||
sb.AppendLine(line.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
static string? FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "Directory.Build.props")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static string? ResolveProtoc(string[] args)
|
||||
{
|
||||
var index = Array.IndexOf(args, "--protoc");
|
||||
if (index >= 0 && index + 1 < args.Length && File.Exists(args[index + 1]))
|
||||
{
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
if (Environment.GetEnvironmentVariable("PROTOC") is { Length: > 0 } fromEnv && File.Exists(fromEnv))
|
||||
{
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
var exe = OperatingSystem.IsWindows() ? "protoc.exe" : "protoc";
|
||||
|
||||
foreach (var pathDir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(pathDir.Trim('"'), exe);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// protoc-*/bin/protoc.exe at a drive root, how it usually gets unzipped
|
||||
foreach (var drive in DriveInfo.GetDrives().Where(d => d.IsReady))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(drive.RootDirectory.FullName, "protoc-*"))
|
||||
{
|
||||
var candidate = Path.Combine(dir, "bin", exe);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static (int ExitCode, string Output) Run(string fileName, string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(fileName)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
info.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(info)!;
|
||||
var stdout = process.StandardOutput.ReadToEnd();
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
return (process.ExitCode, stdout + stderr);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CapySR.ProtoGen;
|
||||
|
||||
// one top-level message/enum plus the metadata comments the dumper put above it
|
||||
public sealed class ProtoDeclaration
|
||||
{
|
||||
public required DeclarationKind Kind { get; init; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public required List<string> Comments { get; init; }
|
||||
|
||||
// body[0] is always "message Foo {"
|
||||
public required List<string> Body { get; init; }
|
||||
|
||||
public required int SourceLine { get; init; }
|
||||
|
||||
public ushort? CmdId { get; init; }
|
||||
|
||||
public string? PacketType { get; init; }
|
||||
|
||||
public string? ObfuscatedName { get; init; }
|
||||
|
||||
public bool WasRenamed { get; private set; }
|
||||
|
||||
public string OriginalName { get; private set; } = string.Empty;
|
||||
|
||||
public void Rename(string newName)
|
||||
{
|
||||
if (OriginalName.Length == 0)
|
||||
{
|
||||
OriginalName = Name;
|
||||
}
|
||||
|
||||
Body[0] = $"{(Kind == DeclarationKind.Message ? "message" : "enum")} {newName} {{";
|
||||
Name = newName;
|
||||
WasRenamed = true;
|
||||
}
|
||||
|
||||
public void Write(StringBuilder sb)
|
||||
{
|
||||
foreach (var comment in Comments)
|
||||
{
|
||||
sb.AppendLine(comment);
|
||||
}
|
||||
|
||||
if (WasRenamed)
|
||||
{
|
||||
sb.AppendLine($"// renamed from duplicate '{OriginalName}'");
|
||||
}
|
||||
|
||||
foreach (var line in Body)
|
||||
{
|
||||
sb.AppendLine(line);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeclarationKind
|
||||
{
|
||||
Message,
|
||||
Enum,
|
||||
}
|
||||
|
||||
// the dump has no package/imports/options, so everything at depth 0 is a declaration
|
||||
public static class ProtoParser
|
||||
{
|
||||
private static readonly Regex DeclarationPattern =
|
||||
new(@"^(?<kind>message|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*\{", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex CmdIdPattern = new(@"^//\s*CmdID:\s*(?<id>\d+)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex TypePattern = new(@"^//\s*Type:\s*(?<type>\w+)\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex ObfPattern = new(@"^//\s*obf:\s*(?<obf>[A-Za-z_][A-Za-z0-9_]*)\s*$", RegexOptions.Compiled);
|
||||
|
||||
public static List<ProtoDeclaration> Parse(string[] lines)
|
||||
{
|
||||
var declarations = new List<ProtoDeclaration>();
|
||||
var pendingComments = new List<string>();
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var trimmed = lines[i].Trim();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
pendingComments.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
pendingComments.Add(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = DeclarationPattern.Match(trimmed);
|
||||
if (!match.Success)
|
||||
{
|
||||
pendingComments.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = i;
|
||||
var depth = 0;
|
||||
var body = new List<string>();
|
||||
|
||||
// no string literals in this dump, so raw brace counting is safe
|
||||
do
|
||||
{
|
||||
var current = lines[i];
|
||||
body.Add(current);
|
||||
depth += current.Count(c => c == '{');
|
||||
depth -= current.Count(c => c == '}');
|
||||
i++;
|
||||
}
|
||||
while (depth > 0 && i < lines.Length);
|
||||
|
||||
i--;
|
||||
|
||||
declarations.Add(new ProtoDeclaration
|
||||
{
|
||||
Kind = match.Groups["kind"].Value == "message" ? DeclarationKind.Message : DeclarationKind.Enum,
|
||||
Name = match.Groups["name"].Value,
|
||||
Comments = [.. pendingComments],
|
||||
Body = body,
|
||||
SourceLine = start + 1,
|
||||
CmdId = ExtractCmdId(pendingComments),
|
||||
PacketType = ExtractSingle(pendingComments, TypePattern, "type"),
|
||||
ObfuscatedName = ExtractSingle(pendingComments, ObfPattern, "obf"),
|
||||
});
|
||||
|
||||
pendingComments.Clear();
|
||||
}
|
||||
|
||||
return declarations;
|
||||
}
|
||||
|
||||
private static ushort? ExtractCmdId(List<string> comments) =>
|
||||
ushort.TryParse(ExtractSingle(comments, CmdIdPattern, "id"), out var value) ? value : null;
|
||||
|
||||
private static string? ExtractSingle(List<string> comments, Regex pattern, string group)
|
||||
{
|
||||
foreach (var comment in comments)
|
||||
{
|
||||
var match = pattern.Match(comment);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[group].Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<#
|
||||
English-patches a CN Star Rail client.
|
||||
|
||||
The language table lives in a .bytes file under
|
||||
StarRail_Data/StreamingAssets/DesignData/Windows. It looks like:
|
||||
|
||||
cn..Chinese(PRC) en..English jp..Japanese kr..Korean
|
||||
os en kr jp en cn cn cn <- voice block
|
||||
os cn en kr jp en cn cn cn <- text block
|
||||
|
||||
Each entry is a length byte (0x02) followed by two ASCII chars, so cn -> en
|
||||
is one byte per entry. The first "cn cn" pair is voice, the second is text.
|
||||
|
||||
Every change is backed up next to the original as *.capysr-backup.
|
||||
Run with -Revert to put the originals back.
|
||||
#>
|
||||
param(
|
||||
[string]$ClientPath,
|
||||
[ValidateSet('en', 'cn', 'jp', 'kr')] [string]$Text = 'en',
|
||||
[ValidateSet('en', 'cn', 'jp', 'kr')] [string]$Voice = '',
|
||||
[switch]$Revert
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $ClientPath) {
|
||||
$configPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'config\config.json'
|
||||
if (Test-Path $configPath) {
|
||||
$ClientPath = (Get-Content $configPath -Raw | ConvertFrom-Json).Client.Path
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ClientPath -or -not (Test-Path $ClientPath)) {
|
||||
Write-Host "client folder not found. pass -ClientPath <folder with StarRail.exe>" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$dir = Join-Path $ClientPath 'StarRail_Data\StreamingAssets\DesignData\Windows'
|
||||
if (-not (Test-Path $dir)) {
|
||||
Write-Host "no DesignData at $dir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Revert) {
|
||||
$backups = Get-ChildItem $dir -Filter '*.capysr-backup' -ErrorAction SilentlyContinue
|
||||
if (-not $backups) { Write-Host 'nothing to revert' -ForegroundColor Yellow; exit 0 }
|
||||
|
||||
foreach ($b in $backups) {
|
||||
$target = $b.FullName -replace '\.capysr-backup$', ''
|
||||
Copy-Item $b.FullName $target -Force
|
||||
Remove-Item $b.FullName -Force
|
||||
Write-Host " reverted $(Split-Path $target -Leaf)" -ForegroundColor Green
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
$patched = 0
|
||||
|
||||
foreach ($file in Get-ChildItem $dir -Filter '*.bytes') {
|
||||
$data = [System.IO.File]::ReadAllBytes($file.FullName)
|
||||
|
||||
# the table sits just past the literal "Korean"
|
||||
$marker = [System.Text.Encoding]::ASCII.GetBytes('Korean')
|
||||
$at = -1
|
||||
for ($i = 0; $i -le $data.Length - $marker.Length; $i++) {
|
||||
$hit = $true
|
||||
for ($j = 0; $j -lt $marker.Length; $j++) {
|
||||
if ($data[$i + $j] -ne $marker[$j]) { $hit = $false; break }
|
||||
}
|
||||
if ($hit) { $at = $i; break }
|
||||
}
|
||||
if ($at -lt 0) { continue }
|
||||
|
||||
# collect the "02 'c' 'n'" entries that follow, in order
|
||||
$entries = @()
|
||||
for ($i = $at + 6; $i -lt [Math]::Min($at + 140, $data.Length - 3); $i++) {
|
||||
if ($data[$i] -eq 0x02 -and $data[$i + 1] -eq 0x63 -and $data[$i + 2] -eq 0x6E) {
|
||||
$entries += ($i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
# voice block then text block; the last two entries of each are the pair
|
||||
if ($entries.Count -lt 6) { continue }
|
||||
|
||||
Write-Host " $($file.Name): $($entries.Count) cn entries at $($entries -join ', ')" -ForegroundColor DarkGray
|
||||
|
||||
$backup = "$($file.FullName).capysr-backup"
|
||||
if (-not (Test-Path $backup)) { Copy-Item $file.FullName $backup }
|
||||
|
||||
function Set-Code($offset, $code) {
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($code)
|
||||
$data[$offset] = $bytes[0]
|
||||
$data[$offset + 1] = $bytes[1]
|
||||
}
|
||||
|
||||
# each block is: standalone cn, then the "cn cn" pair.
|
||||
# block 1 pair = entries 1,2 (voice); block 2 pair = entries 5,6 (text).
|
||||
if ($Voice) {
|
||||
Set-Code $entries[1] $Voice
|
||||
Set-Code $entries[2] $Voice
|
||||
}
|
||||
|
||||
Set-Code $entries[5] $Text
|
||||
Set-Code $entries[6] $Text
|
||||
|
||||
[System.IO.File]::WriteAllBytes($file.FullName, $data)
|
||||
$patched++
|
||||
Write-Host " text -> $Text$(if ($Voice) { ", voice -> $Voice" })" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if ($patched -eq 0) {
|
||||
Write-Host 'no language table found - the client layout may have changed' -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "patched $patched file(s). backups are *.capysr-backup" -ForegroundColor Cyan
|
||||
Write-Host 'revert with: tools\patch-client-language.ps1 -Revert' -ForegroundColor DarkGray
|
||||
Reference in new issue
Block a user