15 Commits

Author SHA1 Message Date
Mikhail
181d1433cf Update dotnet-desktop.yml 2024-07-15 10:33:17 -04:00
Mikhail
1c7292b68b Release v0.1.2 2024-07-15 09:04:28 -04:00
Mikhail
3dfcac2cef disable http port 2024-07-15 09:04:17 -04:00
Mikhail
5134ac187b Implement stage skipping 2024-07-14 19:07:42 -04:00
Mikhail
367cd64b8e add basic admin panel 2024-07-14 15:24:37 -04:00
Mikhail
01f86a7d24 Update StaticDataParser.cs 2024-07-13 20:32:06 -04:00
Mikhail
ecbb9dfe3b implement level up reward, fix xp 2024-07-13 20:25:18 -04:00
Mikhail
b63478f0b4 Validate if launcher/game path is correct 2024-07-13 19:44:30 -04:00
Mikhail
9e79ae0e80 Add discord server link 2024-07-13 12:33:29 -04:00
Mikhail
b34383883b Update LICENSE 2024-07-13 12:32:57 -04:00
Mikhail
71f975b5ce remove debug code 2024-07-12 16:22:33 -04:00
Mikhail
20213153ab Fix inventory system crash
Forgot to assign position value
2024-07-12 16:22:01 -04:00
Mikhail
9356d347bd Add comment about game version 2024-07-12 14:11:39 -04:00
Mikhail
c1d783b1e7 Use DnsClient.Net library to get nk cloud IP 2024-07-12 14:06:45 -04:00
Mikhail
3865b403b4 Update static data, and don't hard code it 2024-07-12 14:01:38 -04:00
44 changed files with 1372 additions and 140 deletions

View File

@@ -42,7 +42,7 @@ jobs:
run: dotnet publish nksrv run: dotnet publish nksrv
- name: Copy to output - name: Copy to output
run: echo ${{ github.workspace }} && md ${{ github.workspace }}/out/ && xcopy "${{ github.workspace }}\ServerSelector.Desktop\bin\Release\net8.0\win-x64\publish\" "${{ github.workspace }}\out\" && xcopy "${{ github.workspace }}\nksrv\bin\Release\net8.0\win-x64\publish\" "${{ github.workspace }}\out\" && copy "${{ github.workspace }}\ServerSelector.Desktop\sodium.dll" "${{ github.workspace }}\out\sodium.dll" run: echo ${{ github.workspace }} && md ${{ github.workspace }}/out/ && xcopy /s /e "${{ github.workspace }}\ServerSelector.Desktop\bin\Release\net8.0\win-x64\publish\" "${{ github.workspace }}\out\" && xcopy /s /e "${{ github.workspace }}\nksrv\bin\Release\net8.0\win-x64\publish\" "${{ github.workspace }}\out\" && copy "${{ github.workspace }}\ServerSelector.Desktop\sodium.dll" "${{ github.workspace }}\out\sodium.dll"
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 Mikhail Thompson Copyright (c) 2024 Mikhail
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -10,8 +10,8 @@ namespace ProtobufViewUtil
{ {
Console.WriteLine("Hello, World!"); Console.WriteLine("Hello, World!");
ResGetOutpostData s = new ResGetOutpostData(); StaticDataPackResponse s = new StaticDataPackResponse();
var inn = File.ReadAllBytes(@"C:\Users\Misha\Downloads\getoutpostdatach2done"); var inn = File.ReadAllBytes(@"C:\Users\Misha\Desktop\response");
s.MergeFrom(inn); s.MergeFrom(inn);
Console.WriteLine(s.ToString()); Console.WriteLine(s.ToString());
var outt = s.ToByteArray(); var outt = s.ToByteArray();

View File

@@ -1,5 +1,18 @@
# nikke-server # nikke-server
Private/local server for Nikke. NOTE: This project is in a very early state so many features in the game do not work.
---
<div align="center">
[![GitHub issues](https://img.shields.io/github/issues/MishaProductions/nikke-server?style=flat-square)](https://github.com/MishaProductions/nikke-server/issues)
[![GitHub pr](https://img.shields.io/github/issues-pr/MishaProductions/nikke-server?style=flat-square)](https://github.com/MishaProductions/nikke-server/pulls)
[![GitHub](https://img.shields.io/github/license/MishaProductions/nikke-server?style=flat-square)](https://github.com/MishaProductions/nikke-server/blob/main/LICENSE)
![GitHub release (with filter)](https://img.shields.io/github/downloads-pre/MishaProductions/nikke-server/latest/total?style=flat-square)
![GitHub Repo stars](https://img.shields.io/github/stars/MishaProductions/nikke-server?style=flat-square)
[![Discord](https://img.shields.io/discord/1261717212448952450?style=flat-square)](https://discord.gg/Ztt6Y9vQjF)
</div>
Private/local server for Nikke. NOTE: This project is in a very early state so many features in the game do not work. Discord server: https://discord.gg/Ztt6Y9vQjF
## Usage ## Usage
Download the latest release/GitHub actions build, and run ServerSelector.Desktop.exe as administrator (to modify DNS hosts file and install a CA cert). Make sure to close the game and launcher first. Select Local server, and then click save. After that, start nksrv.exe to start the actual server. Download the latest release/GitHub actions build, and run ServerSelector.Desktop.exe as administrator (to modify DNS hosts file and install a CA cert). Make sure to close the game and launcher first. Select Local server, and then click save. After that, start nksrv.exe to start the actual server.
@@ -12,6 +25,10 @@ If the game does not get past the title screen, open an issue and send %appdata%
Note that this was tested with the latest version (122.8.20c) Note that this was tested with the latest version (122.8.20c)
To access the admin panel, go to https://127.0.0.1/admin/ and sign in. Note that IsAdmin needs to be true for the user account. Note that this interface does not have anything yet.
To skip stages, a basic command line interface is implemented.
## Progress ## Progress
Stage, character, outpost and story information is saved and works, as well as player nickname. Stage, character, outpost and story information is saved and works, as well as player nickname.

View File

@@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using System; using System;
using System.IO;
using System.Net; using System.Net;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Principal; using System.Security.Principal;
@@ -24,6 +25,30 @@ public partial class MainView : UserControl
return; return;
} }
if (!Directory.Exists(txtGamePath.Text))
{
ShowWarningMsg("Game path folder does not exist", "Error");
return;
}
if (!Directory.Exists(txtLauncherPath.Text))
{
ShowWarningMsg("Launcher folder does not exist", "Error");
return;
}
if (!File.Exists(Path.Combine(txtLauncherPath.Text, "nikke_launcher.exe")))
{
ShowWarningMsg("Launcher path is invalid. Make sure that nikke_launcher.exe exists in the launcher folder", "Error");
return;
}
if (!File.Exists(Path.Combine(txtGamePath.Text, "nikke.exe")))
{
ShowWarningMsg("Game path is invalid. Make sure that nikke.exe exists in the launcher folder", "Error");
return;
}
if (CmbServerSelection.SelectedIndex == 1) if (CmbServerSelection.SelectedIndex == 1)
{ {
if (!IPAddress.TryParse(TxtIpAddress.Text, out _)) if (!IPAddress.TryParse(TxtIpAddress.Text, out _))
@@ -34,6 +59,7 @@ public partial class MainView : UserControl
} }
if (TxtIpAddress.Text == null) TxtIpAddress.Text = ""; if (TxtIpAddress.Text == null) TxtIpAddress.Text = "";
try try
{ {
ServerSwitcher.SaveCfg(CmbServerSelection.SelectedIndex == 0, txtGamePath.Text, txtLauncherPath.Text, TxtIpAddress.Text); ServerSwitcher.SaveCfg(CmbServerSelection.SelectedIndex == 0, txtGamePath.Text, txtLauncherPath.Text, TxtIpAddress.Text);

View File

@@ -0,0 +1,90 @@
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;
using nksrv.Utils;
using System;
using System.Security.Cryptography;
using System.Text;
namespace nksrv
{
public class AdminApiController : WebApiController
{
public static Dictionary<string, User> AdminAuthTokens = new();
private static MD5 md5 = MD5.Create();
[Route(HttpVerbs.Any, "/login")]
public async Task Login()
{
var c = await HttpContext.GetRequestFormDataAsync();
var username = c["username"];
var password = c["password"];
if (HttpContext.Request.HttpMethod != "POST")
{
await HttpContext.SendStringAsync(File.ReadAllText(AppDomain.CurrentDomain.BaseDirectory + "/www/admin/index.html").Replace("<errormsg/>", ""), "text/html", Encoding.Unicode);
return;
}
User? user = null;
bool nullusernames = false;
if (username != null && password != null)
{
var passwordHash = Convert.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(password))).ToLower();
foreach (var item in JsonDb.Instance.Users)
{
if (item.Username == username)
{
if (item.Password.ToLower() == passwordHash)
{
user = item;
}
}
}
}
else
{
nullusernames = true;
}
if (user == null)
{
if (nullusernames == false)
{
await HttpContext.SendStringAsync((string)File.ReadAllText(AppDomain.CurrentDomain.BaseDirectory + "/www/admin/index.html").Replace("<errormsg/>", "Incorrect username or password"), "text/html", Encoding.Unicode);
return;
}
else
{
await HttpContext.SendStringAsync((string)File.ReadAllText(AppDomain.CurrentDomain.BaseDirectory + "/www/admin/index.html").Replace("<errormsg/>", "Please enter a username or password"), "text/html", Encoding.Unicode);
return;
}
}
else
{
if (user.IsAdmin)
{
Response.Headers.Add("Set-Cookie", "token=" + CreateAuthToken(user) + ";path=/");
HttpContext.Redirect("/admin/", 301);
}
else
{
await HttpContext.SendStringAsync((string)File.ReadAllText(AppDomain.CurrentDomain.BaseDirectory + "/www/admin/index.html").Replace("<errormsg/>", "User does not have admin panel access."), "text/html", Encoding.Unicode);
}
}
}
private static string CreateAuthToken(User user)
{
var tok = RandomString(128);
AdminAuthTokens.Add(tok, user);
return tok;
}
public static string RandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[new Random().Next(s.Length)]).ToArray());
}
}
}

View File

@@ -19,6 +19,7 @@ namespace nksrv.IntlServer
protected override async Task HandleAsync() protected override async Task HandleAsync()
{ {
if (ctx == null) throw new Exception("ctx cannot be null");
Console.WriteLine("li-sg redirect in: " + Content); Console.WriteLine("li-sg redirect in: " + Content);
HttpClientHandler handler = new() HttpClientHandler handler = new()
{ {

View File

@@ -19,18 +19,15 @@ namespace nksrv.IntlServer
protected override async Task HandleAsync() protected override async Task HandleAsync()
{ {
RegisterEPReq? ep = JsonConvert.DeserializeObject<RegisterEPReq>(Content); RegisterEPReq? ep = JsonConvert.DeserializeObject<RegisterEPReq>(Content);
if (ep != null) if (ep != null)
{ {
string? seg = ctx.GetRequestQueryData().Get("seq");
// check if the account already exists // check if the account already exists
foreach (var item in JsonDb.Instance.Users) foreach (var item in JsonDb.Instance.Users)
{ {
if (item.Username == ep.account) if (item.Username == ep.account)
{ {
await WriteJsonStringAsync("{\"msg\":\"send code failed; invalid account\",\"ret\":2112,\"seq\":\"" + seg + "\"}"); await WriteJsonStringAsync("{\"msg\":\"send code failed; invalid account\",\"ret\":2112,\"seq\":\"" + Seq + "\"}");
return; return;
} }
} }
@@ -51,7 +48,7 @@ namespace nksrv.IntlServer
JsonDb.Instance.Users.Add(user); JsonDb.Instance.Users.Add(user);
var tok = IntlHandler.CreateLauncherTokenForUser(user); var tok = IntlHandler.CreateLauncherTokenForUser(user);
await WriteJsonStringAsync("{\"expire\":" + tok.ExpirationTime + ",\"is_login\":false,\"msg\":\"Success\",\"register_time\":" + user.RegisterTime + ",\"ret\":0,\"seq\":\"" + seg + "\",\"token\":\"" + tok.Token + "\",\"uid\":\"" + user.ID + "\"}"); await WriteJsonStringAsync("{\"expire\":" + tok.ExpirationTime + ",\"is_login\":false,\"msg\":\"Success\",\"register_time\":" + user.RegisterTime + ",\"ret\":0,\"seq\":\"" + Seq + "\",\"token\":\"" + tok.Token + "\",\"uid\":\"" + user.ID + "\"}");
} }
else else
{ {

View File

@@ -33,9 +33,7 @@ namespace nksrv.IntlServer
} }
} }
string? seg = ctx.GetRequestQueryData().Get("seq"); await WriteJsonStringAsync("{\"msg\":\"the account does not exists!\",\"ret\":2001,\"seq\":\"" + Seq + "\"}");
await WriteJsonStringAsync("{\"msg\":\"the account does not exists!\",\"ret\":2001,\"seq\":\"" + seg + "\"}");
} }
else else
{ {

View File

@@ -12,17 +12,17 @@ namespace nksrv.IntlServer
{ {
public abstract class IntlMsgHandler public abstract class IntlMsgHandler
{ {
protected IHttpContext ctx; protected IHttpContext? ctx;
protected string Content = ""; protected string Content = "";
protected User? User; protected User? User;
protected string? Seq; protected string Seq = "";
protected AccessToken? UsedToken; protected AccessToken? UsedToken;
public abstract bool RequiresAuth { get; } public abstract bool RequiresAuth { get; }
public async Task HandleAsync(IHttpContext ctx) public async Task HandleAsync(IHttpContext ctx)
{ {
this.ctx = ctx; this.ctx = ctx;
Content = await ctx.GetRequestBodyAsStringAsync(); Content = await ctx.GetRequestBodyAsStringAsync();
Seq = ctx.GetRequestQueryData().Get("seq"); Seq = ctx.GetRequestQueryData().Get("seq") ?? "";
if (RequiresAuth) if (RequiresAuth)
{ {
var x = JsonConvert.DeserializeObject<AuthPkt>(Content); var x = JsonConvert.DeserializeObject<AuthPkt>(Content);
@@ -74,7 +74,6 @@ namespace nksrv.IntlServer
await HandleAsync(); await HandleAsync();
} }
protected abstract Task HandleAsync(); protected abstract Task HandleAsync();
protected async Task WriteJsonStringAsync(string data) protected async Task WriteJsonStringAsync(string data)
{ {
if (ctx != null) if (ctx != null)
@@ -83,10 +82,11 @@ namespace nksrv.IntlServer
ctx.Response.ContentEncoding = null; ctx.Response.ContentEncoding = null;
ctx.Response.ContentType = "application/json"; ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bt.Length; ctx.Response.ContentLength64 = bt.Length;
await ctx.Response.OutputStream.WriteAsync(bt, 0, bt.Length, ctx.CancellationToken); await ctx.Response.OutputStream.WriteAsync(bt, ctx.CancellationToken);
await ctx.Response.OutputStream.FlushAsync(); await ctx.Response.OutputStream.FlushAsync();
} }
} }
public class ChannelInfo public class ChannelInfo
{ {
public string openid { get; set; } = ""; public string openid { get; set; } = "";

View File

@@ -20,10 +20,7 @@ namespace nksrv.IntlServer
protected override async Task HandleAsync() protected override async Task HandleAsync()
{ {
var str = await ctx.GetRequestBodyAsStringAsync(); await WriteJsonStringAsync(JsonToReturn.Replace("((SEGID))", Seq));
string? seg = ctx.GetRequestQueryData().Get("seq");
await WriteJsonStringAsync(JsonToReturn.Replace("((SEGID))", seg));
} }
} }
} }

View File

@@ -22,21 +22,19 @@ namespace nksrv.IntlServer
SendCodeRequest? ep = JsonConvert.DeserializeObject<SendCodeRequest>(Content); SendCodeRequest? ep = JsonConvert.DeserializeObject<SendCodeRequest>(Content);
if (ep != null) if (ep != null)
{ {
string? seg = ctx.GetRequestQueryData().Get("seq");
// check if the account already exists // check if the account already exists
foreach (var item in JsonDb.Instance.Users) foreach (var item in JsonDb.Instance.Users)
{ {
if (item.Username == ep.account) if (item.Username == ep.account)
{ {
await WriteJsonStringAsync("{\"msg\":\"send code failed; invalid account\",\"ret\":2112,\"seq\":\"" + seg + "\"}"); await WriteJsonStringAsync("{\"msg\":\"send code failed; invalid account\",\"ret\":2112,\"seq\":\"" + Seq + "\"}");
return; return;
} }
} }
// pretend that we sent the code // pretend that we sent the code
await WriteJsonStringAsync("{\"expire_time\":898,\"msg\":\"Success\",\"ret\":0,\"seq\":\"" + seg + "\"}"); await WriteJsonStringAsync("{\"expire_time\":898,\"msg\":\"Success\",\"ret\":0,\"seq\":\"" + Seq + "\"}");
} }
else else
{ {

View File

@@ -0,0 +1,22 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.Arena
{
[PacketPath("/arena/special/showreward")]
public class ShowSpecialArenaReward : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqShowSpecialArenaReward>();
var response = new ResShowSpecialArenaReward();
// TODO
await WriteDataAsync(response);
}
}
}

View File

@@ -0,0 +1,24 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.FavoriteItem
{
[PacketPath("/favoriteitem/library")]
public class GetFavoriteItemLibrary : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqGetFavoriteItemLibrary>();
var response = new ResGetFavoriteItemLibrary();
var user = GetUser();
await WriteDataAsync(response);
}
}
}

View File

@@ -0,0 +1,23 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.FavoriteItem
{
[PacketPath("/favoriteitem/list")]
public class ListFavoriteItem : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqListFavoriteItem>();
var user = GetUser();
var response = new ResListFavoriteItem();
await WriteDataAsync(response);
}
}
}

View File

@@ -0,0 +1,23 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.FavoriteItem
{
[PacketPath("/favoriteitem/quest/list")]
public class ListFavoriteItemQuests : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqListFavoriteItemQuest>();
var user = GetUser();
var response = new ResListFavoriteItemQuest();
await WriteDataAsync(response);
}
}
}

View File

@@ -23,6 +23,7 @@ namespace nksrv.LobbyServer.Msgs.Inventory
{ {
// update character id // update character id
item.Csn = req.Csn; item.Csn = req.Csn;
item.Position = NetUtils.GetItemPos(user, item.Isn);
} }
} }

View File

@@ -24,7 +24,7 @@ namespace nksrv.LobbyServer.Msgs.Inventory
if (item2 == item.Isn) if (item2 == item.Isn)
{ {
item.Csn = req.Csn; item.Csn = req.Csn;
item.Position = NetUtils.GetItemPos(user, item.Isn);
response.Items.Add(NetUtils.ToNet(item)); response.Items.Add(NetUtils.ToNet(item));
} }
} }

View File

@@ -0,0 +1,25 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.Liberate
{
[PacketPath("/liberate/get")]
public class GetLiberateData : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqGetLiberateData>();
var user = GetUser();
var response = new ResGetLiberateData() { };
// TODO
await WriteDataAsync(response);
}
}
}

View File

@@ -0,0 +1,25 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.Lostsector
{
[PacketPath("/lostsector/get")]
public class GetLostSectorData : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqGetLostSectorData>();
var user = GetUser();
var response = new ResGetLostSectorData();
// TODO
await WriteDataAsync(response);
}
}
}

View File

@@ -15,9 +15,9 @@ namespace nksrv.LobbyServer.Msgs.Misc
var req = await ReadData<ResourceHostRequest>(); var req = await ReadData<ResourceHostRequest>();
var r = new ResourceHostResponse(); var r = new ResourceHostResponse();
r.BaseUrl = "https://cloud.nikke-kr.com/prdenv/122-b0255105e0/{Platform}"; r.BaseUrl = GameConfig.Root.ResourceBaseURL;
await WriteDataAsync(r); await WriteDataAsync(r);
} }
} }
} }

View File

@@ -12,16 +12,14 @@ namespace nksrv.LobbyServer.Msgs.Misc
var req = await ReadData<StaticDataPackRequest>(); var req = await ReadData<StaticDataPackRequest>();
var r = new StaticDataPackResponse(); var r = new StaticDataPackResponse();
r.Url = StaticDataParser.StaticDataUrl; r.Url = GameConfig.Root.StaticData.Url;
r.Version = StaticDataParser.Version; r.Version = GameConfig.Root.StaticData.Version;
r.Size = StaticDataParser.Size; r.Size = StaticDataParser.Instance.Size;
r.Sha256Sum = ByteString.CopyFrom(StaticDataParser.Instance.Sha256Hash);
r.Salt1 = ByteString.CopyFrom(Convert.FromBase64String(GameConfig.Root.StaticData.Salt1));
r.Salt2 = ByteString.CopyFrom(Convert.FromBase64String(GameConfig.Root.StaticData.Salt2));
// TODO: Read the file and compute these values await WriteDataAsync(r);
r.Sha256Sum = ByteString.CopyFrom(StaticDataParser.Sha256Sum);
r.Salt1 = ByteString.CopyFrom(StaticDataParser.Salt1);
r.Salt2 = ByteString.CopyFrom(StaticDataParser.Salt2);
await WriteDataAsync(r);
} }
} }
} }

View File

@@ -0,0 +1,25 @@
using nksrv.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.LobbyServer.Msgs.Sidestory
{
[PacketPath("/sidestory/list")]
public class ListSideStory : LobbyMsgHandler
{
protected override async Task HandleAsync()
{
var req = await ReadData<ReqListSideStory>();
var user = GetUser();
var response = new ResListSideStory();
// TODO
await WriteDataAsync(response);
}
}
}

View File

@@ -19,61 +19,73 @@ namespace nksrv.LobbyServer.Msgs.Stage
var response = new ResClearStage(); var response = new ResClearStage();
var user = GetUser(); var user = GetUser();
// TOOD: save to user info
Console.WriteLine($"Stage " + req.StageId + " completed, result is " + req.BattleResult); Console.WriteLine($"Stage " + req.StageId + " completed, result is " + req.BattleResult);
// TODO: check if user has already cleared this stage
if (req.BattleResult == 1) if (req.BattleResult == 1)
{ {
var clearedStage = StaticDataParser.Instance.GetStageData(req.StageId); response = CompleteStage(user, req.StageId);
if (clearedStage == null) throw new Exception("cleared stage cannot be null");
if (user.FieldInfo.Count == 0)
{
user.FieldInfo.Add("0_" + clearedStage.chapter_mod, new FieldInfo() { });
}
DoQuestSpecificUserOperations(user, req.StageId);
var rewardData = StaticDataParser.Instance.GetRewardTableEntry(clearedStage.reward_id);
if (rewardData != null)
response.StageClearReward = RegisterRewardsForUser(user, rewardData);
else
Logger.Warn("rewardId is null for stage " + req.StageId);
if (clearedStage.stage_category == "Normal" || clearedStage.stage_category == "Boss" || clearedStage.stage_category == "Hard")
{
if (clearedStage.chapter_mod == "Hard")
{
user.LastHardStageCleared = req.StageId;
}
else if (clearedStage.chapter_mod == "Normal")
{
user.LastNormalStageCleared = req.StageId;
}
else
{
Logger.Warn("Unknown chapter mod " + clearedStage.chapter_mod);
}
}
else if (clearedStage.stage_category == "Extra")
{
}
else
{
Logger.Warn("Unknown stage category " + clearedStage.stage_category);
}
user.FieldInfo[(clearedStage.chapter_id - 1) + "_" + clearedStage.chapter_mod].CompletedStages.Add(new NetFieldStageData() { StageId = req.StageId });
JsonDb.Save();
} }
await WriteDataAsync(response); await WriteDataAsync(response);
} }
private NetRewardData RegisterRewardsForUser(Utils.User user, RewardTableRecord rewardData)
public static ResClearStage CompleteStage(Utils.User user, int StageId)
{
var response = new ResClearStage();
var clearedStage = StaticDataParser.Instance.GetStageData(StageId);
if (clearedStage == null) throw new Exception("cleared stage cannot be null");
if (user.FieldInfo.Count == 0)
{
user.FieldInfo.Add("0_" + clearedStage.chapter_mod, new FieldInfo() { });
}
DoQuestSpecificUserOperations(user, StageId);
var rewardData = StaticDataParser.Instance.GetRewardTableEntry(clearedStage.reward_id);
if (rewardData != null)
response.StageClearReward = RegisterRewardsForUser(user, rewardData);
else
Logger.Warn("rewardId is null for stage " + StageId);
if (clearedStage.stage_category == "Normal" || clearedStage.stage_category == "Boss" || clearedStage.stage_category == "Hard")
{
if (clearedStage.chapter_mod == "Hard")
{
user.LastHardStageCleared = StageId;
}
else if (clearedStage.chapter_mod == "Normal")
{
user.LastNormalStageCleared = StageId;
}
else
{
Logger.Warn("Unknown chapter mod " + clearedStage.chapter_mod);
}
}
else if (clearedStage.stage_category == "Extra")
{
}
else
{
Logger.Warn("Unknown stage category " + clearedStage.stage_category);
}
var key = (clearedStage.chapter_id - 1) + "_" + clearedStage.chapter_mod;
if (!user.FieldInfo.ContainsKey(key))
user.FieldInfo.Add(key, new FieldInfo());
user.FieldInfo[key].CompletedStages.Add(new NetFieldStageData() { StageId = StageId });
JsonDb.Save();
return response;
}
private static NetRewardData RegisterRewardsForUser(Utils.User user, RewardTableRecord rewardData)
{ {
NetRewardData ret = new(); NetRewardData ret = new();
if (rewardData.rewards == null) return ret; if (rewardData.rewards == null) return ret;
@@ -81,11 +93,28 @@ namespace nksrv.LobbyServer.Msgs.Stage
if (rewardData.user_exp != 0) if (rewardData.user_exp != 0)
{ {
var newXp = rewardData.user_exp + user.userPointData.ExperiencePoint; var newXp = rewardData.user_exp + user.userPointData.ExperiencePoint;
var newLevel = StaticDataParser.Instance.GetUserLevelFromUserExp(newXp);
var oldXpData = StaticDataParser.Instance.GetUserLevelFromUserExp(user.userPointData.ExperiencePoint);
var xpData = StaticDataParser.Instance.GetUserLevelFromUserExp(newXp);
var newLevel = xpData.Item1;
if (newLevel == -1) if (newLevel == -1)
{ {
Logger.Warn("Unknown user level value for xp " + newXp); Logger.Warn("Unknown user level value for xp " + newXp);
} }
if (newLevel > user.userPointData.UserLevel)
{
newXp -= oldXpData.Item2;
if (user.Currency.ContainsKey(CurrencyType.FreeCash))
user.Currency[CurrencyType.FreeCash] += 30;
else
user.Currency.Add(CurrencyType.FreeCash, 30);
}
// TODO: what is the difference between IncreaseExp and GainExp // TODO: what is the difference between IncreaseExp and GainExp
// NOTE: Current Exp/Lv refers to after XP was added. // NOTE: Current Exp/Lv refers to after XP was added.
@@ -103,10 +132,6 @@ namespace nksrv.LobbyServer.Msgs.Stage
}; };
user.userPointData.ExperiencePoint = newXp; user.userPointData.ExperiencePoint = newXp;
if (newLevel > user.userPointData.UserLevel)
{
// TODO: Commander Level up reward
}
user.userPointData.UserLevel = newLevel; user.userPointData.UserLevel = newLevel;
} }

View File

@@ -53,8 +53,6 @@ namespace nksrv.LobbyServer.Msgs.Stage
if (!found) if (!found)
{ {
Console.WriteLine("chapter not found: " + key);
user.FieldInfo.Add(key, new FieldInfo()); user.FieldInfo.Add(key, new FieldInfo());
return CreateFieldInfo(user, chapter, mod); return CreateFieldInfo(user, chapter, mod);
} }

View File

@@ -47,13 +47,15 @@ namespace nksrv.LobbyServer.Msgs.User
{ {
response.Currency.Add(new NetUserCurrencyData() { Type = (int)item.Key, Value = item.Value }); response.Currency.Add(new NetUserCurrencyData() { Type = (int)item.Key, Value = item.Value });
} }
foreach (var item in user.Characters) foreach (var item in user.Characters)
{ {
response.Character.Add(new NetUserCharacterData() { Default = new() { Csn = item.Csn, Skill1Lv = item.Skill1Lvl, Skill2Lv = item.Skill2Lvl, CostumeId = item.CostumeId, Lv = item.Level, Grade = item.Grade, Tid = item.Tid, UltiSkillLv = item.UltimateLevel } }); response.Character.Add(new NetUserCharacterData() { Default = new() { Csn = item.Csn, Skill1Lv = item.Skill1Lvl, Skill2Lv = item.Skill2Lvl, CostumeId = item.CostumeId, Lv = item.Level, Grade = item.Grade, Tid = item.Tid, UltiSkillLv = item.UltimateLevel } });
} }
foreach (var item in user.Items)
foreach (var item in NetUtils.GetUserItems(user))
{ {
response.Items.Add(new NetUserItemData() { Count = item.Count, Tid = item.ItemType, Csn = item.Csn, Lv = item.Level, Exp = item.Exp, Corporation = item.Corp, Isn = item.Isn, Position = item.Position }); response.Items.Add(item);
} }
// Add squad data if there are characters // Add squad data if there are characters
@@ -85,7 +87,7 @@ namespace nksrv.LobbyServer.Msgs.User
response.LastClearedNormalMainStageId = user.LastNormalStageCleared; response.LastClearedNormalMainStageId = user.LastNormalStageCleared;
await WriteDataAsync(response); await WriteDataAsync(response);
} }
} }
} }

View File

@@ -32,7 +32,7 @@ namespace nksrv.LobbyServer.Msgs.User
response.RepresentationTeam = user.RepresentationTeamData; response.RepresentationTeam = user.RepresentationTeamData;
response.LastClearedNormalMainStageId = user.LastNormalStageCleared; response.LastClearedNormalMainStageId = user.LastNormalStageCleared;
// Restore completed tutorials. GroupID is the first 4 digits of the Table ID. // Restore completed tutorials. GroupID is the first 4 digits of the Table ID.
foreach (var item in user.ClearedTutorialData) foreach (var item in user.ClearedTutorialData)
{ {

View File

@@ -20,6 +20,8 @@ using Newtonsoft.Json.Linq;
using Swan; using Swan;
using Google.Api; using Google.Api;
using nksrv.StaticInfo; using nksrv.StaticInfo;
using EmbedIO.WebApi;
using nksrv.LobbyServer.Msgs.Stage;
namespace nksrv namespace nksrv
{ {
@@ -38,16 +40,202 @@ namespace nksrv
LobbyHandler.Init(); LobbyHandler.Init();
Logger.Info("Starting server"); Logger.Info("Starting server");
new Thread(() =>
{
var server = CreateWebServer();
server.RunAsync();
}).Start();
using var server = CreateWebServer(); // cli interface
await server.RunAsync();
ulong selectedUser = 0;
string prompt = "# ";
while (true)
{
Console.Write(prompt);
var input = Console.ReadLine();
var args = input.Split(' ');
if (string.IsNullOrEmpty(input) || string.IsNullOrWhiteSpace(input))
{
}
else if (input == "?" || input == "help")
{
Console.WriteLine("Nikke Private Server CLI interface");
Console.WriteLine();
Console.WriteLine("Commands:");
Console.WriteLine(" help - show this help");
Console.WriteLine(" ls /users - show all users");
Console.WriteLine(" cd (user id) - select user by id");
Console.WriteLine(" rmuser - delete selected user");
Console.WriteLine(" ban - ban selected user from game");
Console.WriteLine(" unban - unban selected user from game");
Console.WriteLine(" exit - exit server application");
Console.WriteLine(" completestage (chapter num)-(stage number) - complete selected stage and get rewards (and all previous ones). Example completestage 15-1. Note that the exact stage number cleared may not be exact.");
}
else if (input == "ls /users")
{
Console.WriteLine("Id,Username,Nickname");
foreach (var item in JsonDb.Instance.Users)
{
Console.WriteLine($"{item.ID},{item.Username},{item.Nickname}");
}
}
else if (input.StartsWith("cd"))
{
if (args.Length == 2)
{
if (ulong.TryParse(args[1], out ulong id))
{
// check if user id exists
var user = JsonDb.Instance.Users.FirstOrDefault(x => x.ID == id);
if (user != null)
{
selectedUser = user.ID;
Console.WriteLine("Selected user: " + user.Username);
prompt = "/users/" + user.Username + "# ";
}
else
{
Console.WriteLine("User not found");
}
}
else
{
Console.WriteLine("Argument #1 should be a number");
Console.WriteLine("Usage: chroot (user id)");
}
}
else
{
Console.WriteLine("Incorrect number of arguments for chroot");
Console.WriteLine("Usage: chroot (user id)");
}
}
else if (input.StartsWith("rmuser"))
{
if (selectedUser == 0)
{
Console.WriteLine("No user selected");
}
else
{
var user = JsonDb.Instance.Users.FirstOrDefault(x => x.ID == selectedUser);
if (user == null)
{
Console.WriteLine("Selected user does not exist");
selectedUser = 0;
prompt = "# ";
}
else
{
Console.Write("Are you sure you want to delete user " + user.Username + "? (y/n) ");
var confirm = Console.ReadLine();
if (confirm == "y")
{
JsonDb.Instance.Users.Remove(user);
JsonDb.Save();
Console.WriteLine("User deleted");
selectedUser = 0;
prompt = "# ";
}
else
{
Console.WriteLine("User not deleted");
}
}
}
}
else if (input.StartsWith("completestage"))
{
if (selectedUser == 0)
{
Console.WriteLine("No user selected");
}
else
{
var user = JsonDb.Instance.Users.FirstOrDefault(x => x.ID == selectedUser);
if (user == null)
{
Console.WriteLine("Selected user does not exist");
selectedUser = 0;
prompt = "# ";
}
else
{
if (args.Length == 2)
{
var input2 = args[1];
try
{
var chapter = int.TryParse(input2.Split('-')[0], out int chapterNumber);
var stage = int.TryParse(input2.Split('-')[1], out int stageNumber);
if (chapter && stage)
{
for (int i = 0; i < chapterNumber + 1; i++)
{
var stages = StaticDataParser.Instance.GetStageIdsForChapter(i, true);
int target = 1;
foreach (var item in stages)
{
if (!user.IsStageCompleted(item, true))
{
Console.WriteLine("Completing stage " + item);
ClearStage.CompleteStage(user, item);
}
if (i == chapterNumber && target == stageNumber)
{
break;
}
target++;
}
}
}
else
{
Console.WriteLine("chapter and stage number must be a 32 bit integer");
}
}
catch (Exception ex)
{
Console.WriteLine("exception:" + ex.ToString());
}
}
else
{
Console.WriteLine("invalid argument length, must be 1");
}
}
}
}
else if (input == "exit")
{
Environment.Exit(0);
}
else if (input == "ban")
{
Console.WriteLine("Not implemented");
}
else if (input == "unban")
{
Console.WriteLine("Not implemented");
}
else
{
Console.WriteLine("Unknown command");
}
}
} }
private static WebServer CreateWebServer() private static WebServer CreateWebServer()
{ {
var cert = new X509Certificate2(new X509Certificate(AppDomain.CurrentDomain.BaseDirectory + @"site.pfx")); var cert = new X509Certificate2(new X509Certificate(AppDomain.CurrentDomain.BaseDirectory + @"site.pfx"));
var server = new WebServer(o => o var server = new WebServer(o => o
.WithUrlPrefixes("https://*:443", "http://*:80") .WithUrlPrefixes("https://*:443")
.WithMode(HttpListenerMode.EmbedIO).WithAutoLoadCertificate().WithCertificate(cert)) .WithMode(HttpListenerMode.EmbedIO).WithAutoLoadCertificate().WithCertificate(cert))
// First, we will configure our web server by adding Modules. // First, we will configure our web server by adding Modules.
.WithLocalSessionManager() .WithLocalSessionManager()
@@ -60,7 +248,10 @@ namespace nksrv
.WithModule(new ActionModule("/media/", HttpVerbs.Any, HandleAsset)) .WithModule(new ActionModule("/media/", HttpVerbs.Any, HandleAsset))
.WithModule(new ActionModule("/PC/", HttpVerbs.Any, HandleAsset)) .WithModule(new ActionModule("/PC/", HttpVerbs.Any, HandleAsset))
.WithModule(new ActionModule("/$batch", HttpVerbs.Any, HandleBatchRequests)) .WithModule(new ActionModule("/$batch", HttpVerbs.Any, HandleBatchRequests))
.WithModule(new ActionModule("/nikke_launcher", HttpVerbs.Any, HandleLauncherUI)); .WithStaticFolder("/nikke_launcher", Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "www", "launcher"), true)
.WithStaticFolder("/admin/assets/", Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "www", "admin", "assets"), true)
.WithModule(new ActionModule("/admin", HttpVerbs.Any, HandleAdminRequest))
.WithWebApi("/adminapi", m => m.WithController(typeof(AdminApiController)));
// Listen for state changes. // Listen for state changes.
//server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info(); //server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info();
@@ -68,28 +259,59 @@ namespace nksrv
return server; return server;
} }
private static async Task HandleLauncherUI(IHttpContext ctx) private static async Task HandleAdminRequest(IHttpContext context)
{ {
await ctx.SendStringAsync(@"<!DOCTYPE html> //check if user is logged in
<html> if (context.Request.Cookies["token"] == null && context.Request.Url.PathAndQuery != "/api/login")
<head> {
<title>Private Nikke Server Launcher</title> context.Redirect("/adminapi/login");
<style> return;
* { }
color:white;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; //Check if authenticated correctly
} User? currentUser = null;
</style> if (context.Request.Url.PathAndQuery != "/api/login")
</head> {
<body> //verify token
<h1>What's new in Nikke Private Server<h1> foreach (var item in AdminApiController.AdminAuthTokens)
<p>This is the inital release, only story mode works except that you can't collect items and a few other things don't work.</p> {
<p>In order to level up characters, you manually have to edit db.json</p> if (item.Key == context.Request.Cookies["token"].Value)
</body> {
</html> currentUser = item.Value;
", "text/html", Encoding.UTF8); }
}
}
if (currentUser == null)
{
context.Redirect("/adminapi/login");
return;
}
if (context.Request.Url.PathAndQuery == "/admin/")
{
context.Redirect("/admin/dashboard");
}
else if (context.Request.Url.PathAndQuery == "/admin/dashboard")
{
await context.SendStringAsync(ProcessAdminPage("dashbrd.html", currentUser), "text/html", Encoding.Unicode);
}
else
{
context.Response.StatusCode = 404;
await context.SendStringAsync("404 not found", "text/html", Encoding.Unicode);
}
} }
private static string ProcessAdminPage(string pg, User? currentUser)
{
var pgContent = File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "www", "admin", pg));
var nav = File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "www", "admin", "nav.html"));
//navbar
pgContent = pgContent.Replace("{{navbar}}", nav);
return pgContent;
}
private static async Task HandleBatchRequests(IHttpContext ctx) private static async Task HandleBatchRequests(IHttpContext ctx)
{ {
var theBytes = await PacketDecryption.DecryptOrReturnContentAsync(ctx); var theBytes = await PacketDecryption.DecryptOrReturnContentAsync(ctx);

View File

@@ -2195,4 +2195,106 @@ message ReqAllClearEquipment {
message ResAllClearEquipment { message ResAllClearEquipment {
int64 Csn = 2; int64 Csn = 2;
repeated NetUserItemData Items = 3; repeated NetUserItemData Items = 3;
}
message NetFavoriteItemLibraryElement {
int32 Tid = 1;
int64 ReceivedAt = 2;
}
message ReqGetFavoriteItemLibrary {
}
message ResGetFavoriteItemLibrary {
repeated NetFavoriteItemLibraryElement FavoriteItemLibrary = 1;
}
message ReqListFavoriteItem {
}
message ResListFavoriteItem {
repeated NetUserFavoriteItemData FavoriteItems = 1;
}
message NetUserFavoriteItemQuestData {
int32 QuestId = 1;
bool Clear = 2;
bool Received = 3;
}
message ReqListFavoriteItemQuest {
}
message ResListFavoriteItemQuest {
repeated NetUserFavoriteItemQuestData FavoriteItemQuests = 1;
}
message NetUserLostSectorData {
int32 SectorId = 1;
int32 RewardCount = 2;
bool IsFinalReward = 3;
bool IsPlaying = 4;
bool IsOpen = 5;
int32 CurrentClearStageCount = 6;
int32 MaxClearStageCount = 7;
bool IsPerfectReward = 8;
}
message ReqGetLostSectorData {
}
message ResGetLostSectorData {
repeated NetUserLostSectorData LostSector = 2;
int32 LastEnterSectorId = 3;
repeated NetFieldStageData ClearedStages = 4;
}
message NetSideStoryStageData {
int32 SideStoryStageId = 1;
google.protobuf.Timestamp ClearedAt = 2;
}
message ReqListSideStory {}
message ResListSideStory {
repeated NetSideStoryStageData SideStoryStageDataList = 1;
}
enum LiberateMissionState {
LiberateMissionState_Running = 0;
LiberateMissionState_Rewarded = 1;
LiberateMissionState_Closed = 2;
}
message NetLiberateMissionData {
int64 Id = 1;
int32 MissionTid = 2;
int32 LiberateCharacterId = 3;
LiberateMissionState MissionState = 4;
int64 CreatedAt = 6;
int64 TriggerStartAt = 7;
int64 TriggerEndAt = 8;
optional int64 ReceivedAt = 9;
}
message NetLiberateData {
int32 CharacterId = 2;
int32 StepId = 3;
int32 ProgressPoint = 4;
repeated NetLiberateMissionData MissionData = 5;
int32 RewardedCount = 6;
bool IsCompleted = 7;
}
message ReqGetLiberateData {}
message ResGetLiberateData {
repeated int32 OpenLiberateTypeIdList = 2;
NetLiberateData LiberateData = 3;
}
message ReqShowSpecialArenaReward {}
message ResShowSpecialArenaReward {
NetRewardData reward = 4;
/*SpecialArenaContentsState SpecialArenaContentsState = 5;
NetSpecialArenaRewardHistory History = 6;*/
bool IsBan = 7;
NetArenaBanInfo BanInfo = 8;
} }

View File

@@ -26,6 +26,7 @@ namespace nksrv.StaticInfo
/// Can be Normal or Hard /// Can be Normal or Hard
/// </summary> /// </summary>
public string chapter_mod = ""; public string chapter_mod = "";
public string stage_type = "";
} }
public class RewardTableRecord public class RewardTableRecord
{ {

View File

@@ -12,6 +12,7 @@ using ICSharpCode.SharpZipLib.Zip;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Swan.Parsers; using Swan.Parsers;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Drawing;
namespace nksrv.StaticInfo namespace nksrv.StaticInfo
{ {
@@ -20,14 +21,6 @@ namespace nksrv.StaticInfo
/// </summary> /// </summary>
public class StaticDataParser public class StaticDataParser
{ {
// Extracted from staticinfo api call
public const string StaticDataUrl = "https://cloud.nikke-kr.com/prdenv/122-c8cee37754/staticdata/data/qa-240704-07b/312528/StaticData.pack";
public const string Version = "data/qa-240704-07b/312528";
public const int Size = 11799792;
public static byte[] Sha256Sum = Convert.FromBase64String("Wzy+AcGutLR6z1yM7lp+UpFkNuErf56Aj6e9taGH8j4=");
public static byte[] Salt1 = Convert.FromBase64String("vZ3Nv6JwfaZJpHwmUc0kyV7Q3Yzm8ysPhyVE0R0GVTc=");
public static byte[] Salt2 = Convert.FromBase64String("L29mjnvnlktQ1vLq+E56FkRECojiaHx9UmWzsurBfIU=");
// These fields were extracted from the game. // These fields were extracted from the game.
public static byte[] PresharedKey = [0xCB, 0xC2, 0x1C, 0x6F, 0xF3, 0xF5, 0x07, 0xF5, 0x05, 0xBA, 0xCA, 0xD4, 0x98, 0x28, 0x84, 0x1F, 0xF0, 0xD1, 0x38, 0xC7, 0x61, 0xDF, 0xD6, 0xE6, 0x64, 0x9A, 0x85, 0x13, 0x3E, 0x1A, 0x6A, 0x0C, 0x68, 0x0E, 0x2B, 0xC4, 0xDF, 0x72, 0xF8, 0xC6, 0x55, 0xE4, 0x7B, 0x14, 0x36, 0x18, 0x3B, 0xA7, 0xD1, 0x20, 0x81, 0x22, 0xD1, 0xA9, 0x18, 0x84, 0x65, 0x13, 0x0B, 0xED, 0xA3, 0x00, 0xE5, 0xD9]; public static byte[] PresharedKey = [0xCB, 0xC2, 0x1C, 0x6F, 0xF3, 0xF5, 0x07, 0xF5, 0x05, 0xBA, 0xCA, 0xD4, 0x98, 0x28, 0x84, 0x1F, 0xF0, 0xD1, 0x38, 0xC7, 0x61, 0xDF, 0xD6, 0xE6, 0x64, 0x9A, 0x85, 0x13, 0x3E, 0x1A, 0x6A, 0x0C, 0x68, 0x0E, 0x2B, 0xC4, 0xDF, 0x72, 0xF8, 0xC6, 0x55, 0xE4, 0x7B, 0x14, 0x36, 0x18, 0x3B, 0xA7, 0xD1, 0x20, 0x81, 0x22, 0xD1, 0xA9, 0x18, 0x84, 0x65, 0x13, 0x0B, 0xED, 0xA3, 0x00, 0xE5, 0xD9];
public static RSAParameters RSAParameters = new RSAParameters() public static RSAParameters RSAParameters = new RSAParameters()
@@ -61,6 +54,10 @@ namespace nksrv.StaticInfo
private JArray characterCostumeTable; private JArray characterCostumeTable;
private JArray characterTable; private JArray characterTable;
private JArray tutorialTable; private JArray tutorialTable;
private JArray itemEquipTable;
public byte[] Sha256Hash;
public int Size;
static async Task<StaticDataParser> BuildAsync() static async Task<StaticDataParser> BuildAsync()
{ {
@@ -69,6 +66,7 @@ namespace nksrv.StaticInfo
Logger.Info("Parsing static data"); Logger.Info("Parsing static data");
await Instance.Parse(); await Instance.Parse();
return Instance; return Instance;
} }
@@ -86,6 +84,11 @@ namespace nksrv.StaticInfo
characterTable = new(); characterTable = new();
ZipStream = new(); ZipStream = new();
tutorialTable = new(); tutorialTable = new();
itemEquipTable = new();
var rawBytes = File.ReadAllBytes(filePath);
Sha256Hash = SHA256.HashData(rawBytes);
Size = rawBytes.Length;
DecryptStaticDataAndLoadZip(filePath); DecryptStaticDataAndLoadZip(filePath);
if (MainZip == null) throw new Exception("failed to read zip file"); if (MainZip == null) throw new Exception("failed to read zip file");
@@ -95,7 +98,7 @@ namespace nksrv.StaticInfo
{ {
using var fileStream = File.Open(file, FileMode.Open, FileAccess.Read); using var fileStream = File.Open(file, FileMode.Open, FileAccess.Read);
var keyDecryptor = new Rfc2898DeriveBytes(PresharedKey, Salt2, 10000, HashAlgorithmName.SHA256); var keyDecryptor = new Rfc2898DeriveBytes(PresharedKey, GameConfig.Root.StaticData.GetSalt2Bytes(), 10000, HashAlgorithmName.SHA256);
var key2 = keyDecryptor.GetBytes(32); var key2 = keyDecryptor.GetBytes(32);
byte[] decryptionKey = key2[0..16]; byte[] decryptionKey = key2[0..16];
@@ -141,7 +144,7 @@ namespace nksrv.StaticInfo
dataMs.Position = 0; dataMs.Position = 0;
// Decryption of layer 3 // Decryption of layer 3
var keyDecryptor2 = new Rfc2898DeriveBytes(PresharedKey, Salt1, 10000, HashAlgorithmName.SHA256); var keyDecryptor2 = new Rfc2898DeriveBytes(PresharedKey, GameConfig.Root.StaticData.GetSalt1Bytes(), 10000, HashAlgorithmName.SHA256);
var key3 = keyDecryptor2.GetBytes(32); var key3 = keyDecryptor2.GetBytes(32);
byte[] decryptionKey2 = key3[0..16]; byte[] decryptionKey2 = key3[0..16];
@@ -155,8 +158,7 @@ namespace nksrv.StaticInfo
MainZip = new ZipFile(ZipStream, false); MainZip = new ZipFile(ZipStream, false);
} }
public static void AesCtrTransform( public static void AesCtrTransform(byte[] key, byte[] salt, Stream inputStream, Stream outputStream)
byte[] key, byte[] salt, Stream inputStream, Stream outputStream)
{ {
SymmetricAlgorithm aes = Aes.Create(); SymmetricAlgorithm aes = Aes.Create();
aes.Mode = CipherMode.ECB; aes.Mode = CipherMode.ECB;
@@ -208,7 +210,7 @@ namespace nksrv.StaticInfo
} }
public static async Task Load() public static async Task Load()
{ {
var targetFile = await AssetDownloadUtil.DownloadOrGetFileAsync(StaticDataUrl, CancellationToken.None); var targetFile = await AssetDownloadUtil.DownloadOrGetFileAsync(GameConfig.Root.StaticData.Url, CancellationToken.None);
if (targetFile == null) throw new Exception("static data download fail"); if (targetFile == null) throw new Exception("static data download fail");
_instance = new(targetFile); _instance = new(targetFile);
@@ -242,6 +244,7 @@ namespace nksrv.StaticInfo
characterCostumeTable = await LoadZip("CharacterCostumeTable.json"); characterCostumeTable = await LoadZip("CharacterCostumeTable.json");
characterTable = await LoadZip("CharacterTable.json"); characterTable = await LoadZip("CharacterTable.json");
tutorialTable = await LoadZip("ContentsTutorialTable.json"); tutorialTable = await LoadZip("ContentsTutorialTable.json");
itemEquipTable = await LoadZip("ItemEquipTable.json");
} }
public MainQuestCompletionData? GetMainQuestForStageClearCondition(int stage) public MainQuestCompletionData? GetMainQuestForStageClearCondition(int stage)
@@ -316,7 +319,13 @@ namespace nksrv.StaticInfo
return null; return null;
} }
public int GetUserLevelFromUserExp(int targetExp) /// <summary>
/// Returns the level and its minimum value for XP value
/// </summary>
/// <param name="targetExp"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
public (int, int) GetUserLevelFromUserExp(int targetExp)
{ {
int prevLevel = 0; int prevLevel = 0;
int prevValue = 0; int prevValue = 0;
@@ -341,10 +350,10 @@ namespace nksrv.StaticInfo
} }
else else
{ {
return prevLevel; return (prevLevel, prevValue);
} }
} }
return -1; return (-1, -1);
} }
public int GetNormalChapterNumberFromFieldName(string field) public int GetNormalChapterNumberFromFieldName(string field)
{ {
@@ -416,5 +425,45 @@ namespace nksrv.StaticInfo
throw new Exception("tutorial not found: " + TableId); throw new Exception("tutorial not found: " + TableId);
} }
public string? GetItemSubType(int itemType)
{
foreach (JObject item in itemEquipTable)
{
var id = item["id"];
if (id == null) throw new Exception("expected id field in reward data");
int? idValue = id.ToObject<int>();
if (idValue == itemType)
{
var subtype = item["item_sub_type"];
if (subtype == null)
{
throw new Exception("expected item_sub_type field in item equip data");
}
return subtype.ToObject<string>();
}
}
return null;
}
internal IEnumerable<int> GetStageIdsForChapter(int chapterNumber, bool normal)
{
string mod = normal ? "Normal" : "Hard";
foreach (JObject item in stageDataRecords)
{
CampaignStageRecord? data = JsonConvert.DeserializeObject<CampaignStageRecord>(item.ToString());
if (data == null) throw new Exception("failed to deserialize stage data");
int chVal = data.chapter_id - 1;
if (chapterNumber == chVal && data.chapter_mod == mod && data.stage_type == "Main")
{
yield return data.id;
}
}
}
} }
} }

View File

@@ -1,4 +1,5 @@
using Swan.Logging; using DnsClient;
using Swan.Logging;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -11,6 +12,8 @@ namespace nksrv.Utils
public class AssetDownloadUtil public class AssetDownloadUtil
{ {
public static readonly HttpClient AssetDownloader = new(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.All }); public static readonly HttpClient AssetDownloader = new(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.All });
private static string? CloudIp = null;
public static async Task<string?> DownloadOrGetFileAsync(string url, CancellationToken cancellationToken) public static async Task<string?> DownloadOrGetFileAsync(string url, CancellationToken cancellationToken)
{ {
var rawUrl = url.Replace("https://cloud.nikke-kr.com/", ""); var rawUrl = url.Replace("https://cloud.nikke-kr.com/", "");
@@ -27,9 +30,12 @@ namespace nksrv.Utils
{ {
Logger.Info("Download " + targetFile); Logger.Info("Download " + targetFile);
if (CloudIp == null)
{
CloudIp = await GetCloudIpAsync();
}
var requestUri = new Uri("https://" + CloudIp + "/" + rawUrl);
var requestUri = new Uri("https://35.190.17.65/" + rawUrl);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.TryAddWithoutValidation("host", "cloud.nikke-kr.com"); request.Headers.TryAddWithoutValidation("host", "cloud.nikke-kr.com");
using var response = await AssetDownloader.SendAsync(request); using var response = await AssetDownloader.SendAsync(request);
@@ -45,11 +51,23 @@ namespace nksrv.Utils
} }
else else
{ {
Logger.Error("Failed to download " + url + " with status code " + response.StatusCode);
return null; return null;
} }
} }
return targetFile; return targetFile;
} }
private static async Task<string> GetCloudIpAsync()
{
var lookup = new LookupClient();
var result = await lookup.QueryAsync("cloud.nikke-kr.com", QueryType.A);
var record = result.Answers.ARecords().FirstOrDefault();
var ip = record?.Address;
return ip.ToString();
}
} }
} }

64
nksrv/Utils/GameConfig.cs Normal file
View File

@@ -0,0 +1,64 @@
using Newtonsoft.Json;
using Swan.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace nksrv.Utils
{
public class GameConfigRoot
{
public StaticData StaticData { get; set; } = new();
public string ResourceBaseURL { get; set; } = "";
}
public class StaticData
{
public string Url { get; set; } = "";
public string Version { get; set; } = "";
public string Salt1 { get; set; } = "";
public string Salt2 { get; set; } = "";
public byte[] GetSalt1Bytes()
{
return Convert.FromBase64String(Salt1);
}
public byte[] GetSalt2Bytes()
{
return Convert.FromBase64String(Salt2);
}
}
public static class GameConfig
{
private static GameConfigRoot? _root;
public static GameConfigRoot Root
{
get
{
if (_root == null)
{
if (!File.Exists(AppDomain.CurrentDomain.BaseDirectory + "/gameconfig.json"))
{
Logger.Error("Gameconfig.json is not found, the game WILL NOT work!");
_root = new GameConfigRoot();
}
Logger.Info("Loaded game config");
_root = JsonConvert.DeserializeObject<GameConfigRoot>(File.ReadAllText(AppDomain.CurrentDomain.BaseDirectory + "/gameconfig.json"));
if (_root == null)
{
throw new Exception("Failed to read gameconfig.json");
}
}
return _root;
}
}
}
}

View File

@@ -1,6 +1,7 @@
using ASodium; using ASodium;
using Newtonsoft.Json; using Newtonsoft.Json;
using nksrv.LobbyServer; using nksrv.LobbyServer;
using nksrv.LobbyServer.Msgs.Stage;
using nksrv.StaticInfo; using nksrv.StaticInfo;
using Swan.Logging; using Swan.Logging;
using System; using System;
@@ -78,6 +79,7 @@ namespace nksrv.Utils
public string Nickname = "SomePlayer"; public string Nickname = "SomePlayer";
public int ProfileIconId = 39900; public int ProfileIconId = 39900;
public bool ProfileIconIsPrism = false; public bool ProfileIconIsPrism = false;
public bool IsAdmin = false;
// Game data // Game data
public List<string> CompletedScenarios = []; public List<string> CompletedScenarios = [];
@@ -134,10 +136,28 @@ namespace nksrv.Utils
return num; return num;
} }
public bool IsStageCompleted(int id, bool isNorm)
{
foreach (var item in FieldInfo)
{
if (item.Key.Contains("hard") && isNorm) continue;
if (item.Key.Contains("normal") && !isNorm) continue;
foreach (var s in item.Value.CompletedStages)
{
if (s.StageId == id)
{
return true;
}
}
}
return false;
}
} }
public class CoreInfo public class CoreInfo
{ {
public int DbVersion = 0; public int DbVersion = 2;
public List<User> Users = []; public List<User> Users = [];
public List<AccessToken> LauncherAccessTokens = []; public List<AccessToken> LauncherAccessTokens = [];
@@ -187,6 +207,20 @@ namespace nksrv.Utils
} }
Console.WriteLine("Database update completed"); Console.WriteLine("Database update completed");
} }
else if (Instance.DbVersion == 1)
{
Console.WriteLine("Starting database update...");
// there was a bug where equipment position was not saved, so remove all items from each characters
Instance.DbVersion = 2;
foreach (var user in Instance.Users)
{
foreach (var f in user.Items.ToList())
{
f.Csn = 0;
}
}
Console.WriteLine("Database update completed");
}
Save(); Save();
} }
else else

View File

@@ -1,4 +1,9 @@
 
using nksrv.StaticInfo;
using Swan.Logging;
using System.Reflection;
namespace nksrv.Utils namespace nksrv.Utils
{ {
public class NetUtils public class NetUtils
@@ -17,5 +22,61 @@ namespace nksrv.Utils
Tid = item.ItemType Tid = item.ItemType
}; };
} }
public static List<NetUserItemData> GetUserItems(User user)
{
List<NetUserItemData> ret = new();
Dictionary<int, NetUserItemData> itemDictionary = new Dictionary<int, NetUserItemData>();
foreach (var item in user.Items.ToList())
{
if (item.Csn == 0)
{
if (itemDictionary.ContainsKey(item.ItemType))
{
itemDictionary[item.ItemType].Count++;
}
else
{
itemDictionary[item.ItemType] = new NetUserItemData() { Count = item.Count, Tid = item.ItemType, Csn = item.Csn, Lv = item.Level, Exp = item.Exp, Corporation = item.Corp, Isn = item.Isn, Position = item.Position };
}
}
else
{
var newItem = new NetUserItemData() { Count = item.Count, Tid = item.ItemType, Csn = item.Csn, Lv = item.Level, Exp = item.Exp, Corporation = item.Corp, Isn = item.Isn, Position = item.Position };
itemDictionary[item.ItemType] = newItem;
}
}
return ret;
}
public static int GetItemPos(User user, long isn)
{
foreach (var item in user.Items)
{
if (item.Isn == isn)
{
var subType = StaticDataParser.Instance.GetItemSubType(item.ItemType);
switch(subType)
{
case "Module_A":
return 0;
case "Module_B":
return 1;
case "Module_C":
return 2;
case "Module_D":
return 3;
default:
Logger.Warn("Unknown item subtype: " + subType);
break;
}
break;
}
}
return 0;
}
} }
} }

13
nksrv/gameconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
// Asset Urls for game version 122.8.20f
// Extracted from POST https://global-lobby.nikke-kr.com/v1/staticdatapack
"StaticData": {
"Url": "https://cloud.nikke-kr.com/prdenv/122-c8cee37754/staticdata/data/qa-240704-07b/313275/StaticData.pack",
"Version": "data/qa-240704-07b/313275",
"Salt1": "7OpvuafRK67Rf0X2VJrzIAqZ0CBPbY4IWWdtbQ3LyV8=",
"Salt2": "zR7nPjsRCPUfN9BViVkk5R/KOCkVimb8VSE+yOqey+g="
},
// Extracted from POST https://global-lobby.nikke-kr.com/v1/resourcehosts2
"ResourceBaseURL": "https://cloud.nikke-kr.com/prdenv/122-b0255105e0/{Platform}"
}

View File

@@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ASodium" Version="0.6.1" /> <PackageReference Include="ASodium" Version="0.6.1" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="EmbedIO" Version="3.5.2" /> <PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="Google.Api.CommonProtos" Version="2.15.0" /> <PackageReference Include="Google.Api.CommonProtos" Version="2.15.0" />
<PackageReference Include="Google.Protobuf.Tools" Version="3.27.1" /> <PackageReference Include="Google.Protobuf.Tools" Version="3.27.1" />
@@ -29,8 +30,14 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="gameconfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="site.pfx"> <None Update="site.pfx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<None Update="www\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,23 @@
@ -0,0 +1,23 @@ html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: url('/admin/assets/login.jpg') no-repeat center center fixed;
background-size: cover;
/* Center child horizontally*/
display: flex;
justify-content: center;
align-items: center;
}
.LoginBox {
background-color: white;
border-radius: 10px;
padding: 20px 20px 20px 20px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -0,0 +1,239 @@
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.bold {
font-weight: bold;
}
.input {
margin: 5px 0px 5px 0;
}
/* Tabs */
/* Style the tab */
.tab {
float: left;
border: 1px solid #ccc;
background-color: #f1f1f1;
width: 30%;
height: 1000px;
}
/* Style the buttons inside the tab */
.tab button {
display: block;
background-color: inherit;
color: black;
padding: 22px 16px;
width: 100%;
border: none;
outline: none;
text-align: left;
cursor: pointer;
transition: 0.3s;
font-size: 17px;
}
/* Change background color of buttons on hover */
.tab button:hover {
background-color: #ddd;
}
/* Create an active/current "tab button" class */
.tab button.active {
background-color: #ccc;
}
/* Style the tab content */
.tabcontent {
float: left;
padding: 5px 5px 5px 5px;
border: 1px solid #ccc;
width: 70%;
border-left: none;
height: 1000px;
}
.navbar2 {
margin: 0;
padding: 0;
overflow: hidden;
background-color: var(--main-nav-light-bg);
}
.navbar-item {
float: left;
display: block;
color: var(--main-nav-light-color);
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
.navbar-item:hover {
background-color: #029761;
text-decoration: none;
color: var(--main-nav-light-color);
}
.navbar-item:not(.active):hover {
background-color: var(--main-nav-light-hover);
text-decoration: none;
color: var(--main-nav-light-color);
}
a:hover {
text-decoration: none;
}
.active {
background-color: #04AA6D;
}
:root {
--main-nav-dark-bg: #333;
--main-nav-dark-color: white;
--main-nav-dark-hover: #111;
--main-nav-light-bg: #f1f1f1;
--main-nav-light-color: black;
--main-nav-light-hover: #dddddd;
}
.form-control {
width: 200px;
}
zonegroup {
display: flex;
}
zonegroup p {
display: flex;
padding-right: 15px;
}
zonegroup input {
display: flex;
}
.right {
float: right;
}
.dropdown2 {
overflow: hidden;
}
.dropdown2 .dropbtn {
cursor: pointer;
font-size: 16px;
border: none;
outline: none;
color: black;
padding: 14px 16px;
background-color: inherit;
font-family: inherit;
margin: 0;
}
.navbar a:hover, .dropdown2:hover .dropbtn, .dropbtn:focus {
background-color: green;
color: white;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f9f9f9;
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
z-index: 1;
}
.dropdown-content a {
color: black;
padding: 12px 16px 12px 7px;
text-decoration: none;
display: block;
text-align: left;
}
.dropdown-content a:hover {
background-color: #ddd;
}
.show {
display: block;
}
/* Leave this at the end */
@media (prefers-color-scheme: dark) {
body {
background-color: rgb(25, 25, 25);
color: white;
}
.app-bar {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
/* For browsers that do not support gradients */
background-color: #A3A6A9;
background-image: linear-gradient(to right, #A3A6A9, #666765);
}
.button {
background-color: darkgray;
color: white;
}
.tab {
background-color: rgb(36,36,36);
}
.tab button {
color: white;
}
.tab button:hover {
background-color: #666765;
}
.tab button.active {
background-color: darkgray;
}
.navbar2 {
background-color: var(--main-nav-dark-bg);
}
.navbar-item {
color: var(--main-nav-dark-color);
}
.navbar-item:hover {
background-color: #029761;
text-decoration: none;
color: var(--main-nav-dark-color);
}
.navbar-item:not(.active):hover {
background-color: var(--main-nav-dark-hover);
text-decoration: none;
color: var(--main-nav-dark-color);
}
.modal {
--bs-modal-bg: black;
}
.dropdown2 .dropbtn {
color: white;
}
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Security System Controller</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/assets/style.css">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
</head>
<body>
{{navbar}}
<div class="containter">
<h1>Welcome to Nikke Private Server Admin Panel</h1>
<p>There are no settings to display.</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Security System Controller</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<link rel="stylesheet" href="/admin/assets/login.css">
<link rel="icon" href="./favicon.ico" type="image/x-icon">
</head>
<body>
<div class="LoginBox">
<h1>Login</h1>
<form action="/adminapi/login" method="post">
<div class="mb-3">
<label for="UsernameBox" class="form-label">Username</label>
<input type="text" class="form-control" id="UsernameBox" name="username">
</div>
<div class="mb-3">
<label for="PasswordBox" class="form-label">Password</label>
<input type="password" class="form-control" id="PasswordBox" name="password">
</div>
<errormsg/>
<br />
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
</body>
</html>

3
nksrv/www/admin/nav.html Normal file
View File

@@ -0,0 +1,3 @@
<div class="navbar2">
<a href="/admin/dashboard" class="navbar-item">Overview</a>
</div>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>Private Nikke Server Launcher</title>
<style>
* {
color: white;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
</style>
</head>
<body>
<h1>What's new in Nikke Private Server - v0.1.2</h1>
<ul>
<li>Fixed static download fail</li>
<li>Asset server IP address is no longer hardcoded.</li>
<li>Skipping stages is now implemented via command line interface</li>
<li>Added required messages for ch15+</li>
<li>Fixed XP system</li>
<li>Fixed game crash due to inventory system</li>
<li>Check if game path / launcher path is correct in server selector</li>
<li>Implemented commander level up reward</li>
<li>Began work on admin panel</li>
</ul>
</body>
</html>