feat(init): server has been rewritten

This commit is contained in:
capyb2222 committed 2026-09-07 22:19:20 -07:00
1 parent 3647ed19c4
commit b87874f2fe
84 files changed
+1437031 -1

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
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
+19
View File
@@ -0,0 +1,19 @@
bin/
obj/
*.user
.vs/
artifacts/
*.log
logs/
# account data written by srtools and by the server itself
data/
freesr-data.json
# game data and reference servers are separate checkouts, not part of CapySR
turnbasedgamedata/
March7thHoney-OpenSource/
pearl-sr/
reference-robinsr/
reference-himeko-nova-sr/
TurnBaseGameProto/
+16
View File
@@ -0,0 +1,16 @@
<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>
+13
View File
@@ -0,0 +1,13 @@
<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>
+111 -1
View File
@@ -1,3 +1,113 @@
# CapySR
Work In Progress, will be release soon.
A Honkai: Star Rail server reimplementation rewritten in C#.
Current version: `CNBETAWin4.5.52`
Goals: a full combat system including combat events, and
[srtools](https://srtools.neonteam.dev/) support. Currency Wars and Divergent Universe might be focused on in the far future.
## Requirements
- .NET 10 SDK
- `protoc` (only to regenerate the protocol)
## Running
```
dotnet run --project src/CapySR.SdkServer # dispatch + sdk, port 21000
dotnet run --project src/CapySR.GameServer # kcp gateway, port 23301
```
Or just `run.bat`.
## What works
- Teams: six squads, join/quit/swap/leader/rename/switch, virtual lineups for challenges,
shared technique points (Phainon and Himeko Nova raise the cap), all remembered between runs.
- Overworld fights: hitting a monster resolves its own stage for your world level, the attacker's
element breaks toughness on entry, ambushes work, destructible props break, and fodder-clearing
techniques (Acheron, Phainon, Silver Wolf 999, Himeko Nova) skip the fight. Won fights remove
the monsters; HP and energy carry over until you teleport.
- Calyxes, Stagnant Shadows / Caverns, Battle College (trial characters), story stages by event.
- Memory of Chaos, Pure Fiction, Apocalyptic Shadow with both nodes, scores, stars, records; Anomaly
Arbitration with saved mob teams, hard mode and settle screens. Every floor reports as cleared so
none sit locked behind the one before them; set `GameServer.UnlockAllChallenges` to false to
report only what has actually been played.
- Position, paths, gender, head icon, server-side settings and challenge records persist in
`data/player.json`.
## Chat commands
The friend list has one entry, CapySR. Message it:
| command | effect |
|-----------------------------|-----------------------------------------------------|
| `/help` | list the commands |
| `/tp <entryId> [teleportId]`| travel to a map entrance |
| `/unstuck` | respawn at the current map's anchor |
| `/heal` | full health and technique points |
| `/mp [n]` | set technique points |
| `/wl <0-6>` | set the equilibrium level (monsters scale with it) |
| `/stage <stageId>` | the next fight you start plays that stage |
| `/sync` | reload `freesr-data.json` now |
| `/lineup`, `/pos`, `/clear` | show the team, show where you are, clear the map |
## srtools
Point srtools at `http://127.0.0.1:21000/srtools` and upload. The game server watches
`data/freesr-data.json` and pushes the new roster, gear and teams to a logged-in client without a relog.
`GameServer.BattleSource` in `config/config.json` decides what the uploaded `battle_config` drives:
| value | behaviour |
|---------------------|---------------------------------------------------------------|
| `auto` (default) | every non-challenge fight plays the srtools battle |
| `srtools` | the same, and never falls back to the encounter's own stage |
| `stage` | everything plays its real stage |
So a calyx runs whatever stage `freesr-data.json` names. That is how an older Apocalyptic
Shadow, Pure Fiction or Memory of Chaos floor gets replayed, while F4 keeps the current
rotation. Challenges always play their own stage.
### Hotfix URLs
For OS:
```json
{
"OSBETAWin4.5.51": {
"asset_bundle_url": "https://autopatchos.starrails.com/asb/...",
"ex_resource_url": "https://autopatchos.starrails.com/design_data/...",
"lua_url": "https://autopatchos.starrails.com/lua/...",
"ifix_url": "https://autopatchos.starrails.com/ifix/..."
}
}
```
## Tests
```
dotnet test tests/CapySR.Tests
```
`FlowTests` drive a real gateway end to end: login, team edits, an overworld fight, technique
points, and a two-node Memory of Chaos floor.
## Regenerating the protocol
```
dotnet run --project tools/CapySR.ProtoGen
```
The generated protocol is committed, so this is only needed after a proto dump changes. Clone
[TurnBaseGameProto](https://github.com/Mar7thLover/TurnBaseGameProto) next to the solution first;
game data (`turnbasedgamedata`, `pearl-sr/resources`) and the reference servers are separate
checkouts too and are not tracked here.
Reads `TurnBaseGameProto/Raw/StarRail.proto`, resolves the 11 duplicate type names the dump's
translation pass produces, runs protoc, and writes `CmdId.g.cs` / `CmdIdTable.g.cs`. Duplicate
resolution is scored against the `CmdXxxType` service enums and `packetIds.txt`; deliberate calls
live in `DuplicateResolver.Overrides`.
## Credits
- capyb2222
- Various other server reimplementation.
+46
View File
@@ -0,0 +1,46 @@
{
"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": [
"turnbasedgamedata/ExcelOutput",
"pearl-sr/resources"
]
},
"Client": {
"Path": "C:\\Users\\bachk\\Downloads\\StarRail_4.5.52_CN_ReversedRooms"
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"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"
}
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start.ps1" %*
if errorlevel 1 pause
+11
View File
@@ -0,0 +1,11 @@
<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>
+97
View File
@@ -0,0 +1,97 @@
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",
_ => "????",
};
}
}
+158
View File
@@ -0,0 +1,158 @@
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 =
[
"turnbasedgamedata/ExcelOutput",
"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();
}
}
+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\CapySR.Common\CapySR.Common.csproj" />
</ItemGroup>
</Project>
+109
View File
@@ -0,0 +1,109 @@
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";
}
+191
View File
@@ -0,0 +1,191 @@
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; }
}
// 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;
}
+310
View File
@@ -0,0 +1,310 @@
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();
}
+118
View File
@@ -0,0 +1,118 @@
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; }
}
+117
View File
@@ -0,0 +1,117 @@
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;
}
+414
View File
@@ -0,0 +1,414 @@
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; } = [];
// 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 roots = sources.Where(Directory.Exists).ToArray();
if (roots.Length == 0)
{
throw new DirectoryNotFoundException(
"no game data sources found. Clone Dimbreath/turnbasedgamedata and point " +
"Data:Sources at its ExcelOutput folder.");
}
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.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);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
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,
}
@@ -0,0 +1,551 @@
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);
}
}
}
@@ -0,0 +1,572 @@
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;
}
}
@@ -0,0 +1,47 @@
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;
}
@@ -0,0 +1,84 @@
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;
}
}
@@ -0,0 +1,102 @@
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,
};
}
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,318 @@
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;
}
}
+145
View File
@@ -0,0 +1,145 @@
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);
}
}
+348
View File
@@ -0,0 +1,348 @@
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;
}
}
+192
View File
@@ -0,0 +1,192 @@
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; } = [];
}
+286
View File
@@ -0,0 +1,286 @@
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; }
}
}
+111
View File
@@ -0,0 +1,111 @@
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,
};
}
}
@@ -0,0 +1,127 @@
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,
});
}
}
@@ -0,0 +1,186 @@
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);
}
}
@@ -0,0 +1,479 @@
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;
}
}
@@ -0,0 +1,375 @@
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()];
}
}
@@ -0,0 +1,181 @@
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).");
}
}
@@ -0,0 +1,115 @@
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 },
},
},
};
}
@@ -0,0 +1,230 @@
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);
}
}
@@ -0,0 +1,252 @@
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);
}
}
@@ -0,0 +1,72 @@
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,
});
public static Task OnHeartBeat(PlayerSession session, PlayerHeartBeatCsReq request) =>
session.SendAsync(new PlayerHeartBeatScRsp
{
Retcode = 0,
ClientTimeMs = request.ClientTimeMs,
ServerTimeMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
@@ -0,0 +1,93 @@
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
{
// 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 });
public static Task OnGetGachaInfo(PlayerSession session, GetGachaInfoCsReq request) =>
session.SendAsync(new GetGachaInfoScRsp { Retcode = 0 });
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,
});
}
@@ -0,0 +1,69 @@
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);
}
session.Logger.LogInformation(
"missions: {Sub} sub finished, {Main} main finished",
response.SubMissionStatusList.Count, response.FinishedMainMissionIdList.Count);
return session.SendAsync(response);
}
public static Task OnGetMainMissionCustomValue(PlayerSession session, GetMainMissionCustomValueCsReq request) =>
session.SendAsync(new GetMainMissionCustomValueScRsp { Retcode = 0 });
// 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);
}
}
@@ -0,0 +1,125 @@
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,
},
});
}
@@ -0,0 +1,228 @@
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);
}
}
}
@@ -0,0 +1,366 @@
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();
}
}
}
@@ -0,0 +1,45 @@
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
{
public static Task OnUnlockTutorial(PlayerSession session, UnlockTutorialCsReq request) =>
session.SendAsync(new UnlockTutorialScRsp { Retcode = 0 });
public static Task OnFinishTutorial(PlayerSession session, FinishTutorialCsReq request) =>
session.SendAsync(new FinishTutorialScRsp { Retcode = 0 });
public static Task OnUnlockTutorialGuide(PlayerSession session, UnlockTutorialGuideCsReq request) =>
session.SendAsync(new UnlockTutorialGuideScRsp { Retcode = 0 });
public static Task OnFinishTutorialGuide(PlayerSession session, FinishTutorialGuideCsReq request) =>
session.SendAsync(new FinishTutorialGuideScRsp { Retcode = 0 });
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 });
}
@@ -0,0 +1,80 @@
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!);
}
+172
View File
@@ -0,0 +1,172 @@
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 _);
}
}
}
}
}
+329
View File
@@ -0,0 +1,329 @@
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;
}
}
}
+48
View File
@@ -0,0 +1,48 @@
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);
var world = new GameWorld(config, logger);
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);
+229
View File
@@ -0,0 +1,229 @@
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 };
}
+160
View File
@@ -0,0 +1,160 @@
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
View File
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
+763
View File
@@ -0,0 +1,763 @@
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; }
}
}
@@ -0,0 +1,16 @@
<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
@@ -0,0 +1,936 @@
// <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
+163
View File
@@ -0,0 +1,163 @@
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
+143
View File
@@ -0,0 +1,143 @@
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.");
}
}
@@ -0,0 +1,12 @@
<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>
+191
View File
@@ -0,0 +1,191 @@
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;
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)
{
return cached;
}
await _lock.WaitAsync();
try
{
if (_versions.TryGetValue(version, out cached) && cached.IsUsable)
{
return cached;
}
var fetched = await FetchAsync(version, dispatchSeed);
if (fetched is null)
{
return cached;
}
_versions[version] = fetched;
Save();
return fetched;
}
finally
{
_lock.Release();
}
}
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,
};
}
+140
View File
@@ -0,0 +1,140 @@
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();
+91
View File
@@ -0,0 +1,91 @@
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;
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
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,
};
}
+54
View File
@@ -0,0 +1,54 @@
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);
}
}
}
}
+119
View File
@@ -0,0 +1,119 @@
#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 '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 ''
+401
View File
@@ -0,0 +1,401 @@
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));
}
}
+21
View File
@@ -0,0 +1,21 @@
<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>
+324
View File
@@ -0,0 +1,324 @@
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}");
}
}
+591
View File
@@ -0,0 +1,591 @@
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 readonly GameWorld World = new(TestConfig(), NullLogger.Instance);
// 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 => new DataConfig().ResolvedSources.Any(Directory.Exists);
[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()
{
var configured = World.Player.BattleConfig;
if (!HasData || 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;
}
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));
}
}
+286
View File
@@ -0,0 +1,286 @@
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);
}
[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);
}
}
@@ -0,0 +1,239 @@
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 readonly GameWorld World =
new(new ServerConfig(), NullLogger.Instance);
[Fact]
public async Task ClientCompletesHandshakeAndLogin()
{
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()
{
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();
}
+93
View File
@@ -0,0 +1,93 @@
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;
}
}
+123
View File
@@ -0,0 +1,123 @@
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;
}
}
+234
View File
@@ -0,0 +1,234 @@
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);
}
}
+105
View File
@@ -0,0 +1,105 @@
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);
}
}
+200
View File
@@ -0,0 +1,200 @@
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);
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
+155
View File
@@ -0,0 +1,155 @@
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);
}
}
}
}
+419
View File
@@ -0,0 +1,419 @@
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, "TurnBaseGameProto", "Raw", "StarRail.proto");
var packetIds = Path.Combine(repoRoot, "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);
}
+158
View File
@@ -0,0 +1,158 @@
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;
}
}
+118
View File
@@ -0,0 +1,118 @@
<#
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