16 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
Mikhail
618619e36d fix static data download fail 2024-07-12 07:56:43 -04:00
44 changed files with 1393 additions and 151 deletions

View File

@@ -42,7 +42,7 @@ jobs:
run: dotnet publish nksrv
- 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
uses: actions/upload-artifact@v4

View File

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

View File

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

View File

@@ -1,5 +1,18 @@
# 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
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)
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
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 System;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Principal;
@@ -24,6 +25,30 @@ public partial class MainView : UserControl
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 (!IPAddress.TryParse(TxtIpAddress.Text, out _))
@@ -34,6 +59,7 @@ public partial class MainView : UserControl
}
if (TxtIpAddress.Text == null) TxtIpAddress.Text = "";
try
{
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()
{
if (ctx == null) throw new Exception("ctx cannot be null");
Console.WriteLine("li-sg redirect in: " + Content);
HttpClientHandler handler = new()
{

View File

@@ -19,18 +19,15 @@ namespace nksrv.IntlServer
protected override async Task HandleAsync()
{
RegisterEPReq? ep = JsonConvert.DeserializeObject<RegisterEPReq>(Content);
if (ep != null)
{
string? seg = ctx.GetRequestQueryData().Get("seq");
// check if the account already exists
foreach (var item in JsonDb.Instance.Users)
{
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;
}
}
@@ -51,7 +48,7 @@ namespace nksrv.IntlServer
JsonDb.Instance.Users.Add(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
{

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\":\"" + seg + "\"}");
await WriteJsonStringAsync("{\"msg\":\"the account does not exists!\",\"ret\":2001,\"seq\":\"" + Seq + "\"}");
}
else
{

View File

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

View File

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

View File

@@ -22,21 +22,19 @@ namespace nksrv.IntlServer
SendCodeRequest? ep = JsonConvert.DeserializeObject<SendCodeRequest>(Content);
if (ep != null)
{
string? seg = ctx.GetRequestQueryData().Get("seq");
// check if the account already exists
foreach (var item in JsonDb.Instance.Users)
{
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;
}
}
// 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
{

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
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)
{
item.Csn = req.Csn;
item.Position = NetUtils.GetItemPos(user, item.Isn);
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 r = new ResourceHostResponse();
r.BaseUrl = "https://cloud.nikke-kr.com/prdenv/122-b0255105e0/{Platform}";
await WriteDataAsync(r);
r.BaseUrl = GameConfig.Root.ResourceBaseURL;
await WriteDataAsync(r);
}
}
}

View File

@@ -12,16 +12,14 @@ namespace nksrv.LobbyServer.Msgs.Misc
var req = await ReadData<StaticDataPackRequest>();
var r = new StaticDataPackResponse();
r.Url = StaticDataParser.StaticDataUrl;
r.Version = StaticDataParser.Version;
r.Size = StaticDataParser.Size;
r.Url = GameConfig.Root.StaticData.Url;
r.Version = GameConfig.Root.StaticData.Version;
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
r.Sha256Sum = ByteString.CopyFrom(StaticDataParser.Sha256Sum);
r.Salt1 = ByteString.CopyFrom(StaticDataParser.Salt1);
r.Salt2 = ByteString.CopyFrom(StaticDataParser.Salt2);
await WriteDataAsync(r);
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 user = GetUser();
// TOOD: save to user info
Console.WriteLine($"Stage " + req.StageId + " completed, result is " + req.BattleResult);
// TODO: check if user has already cleared this stage
if (req.BattleResult == 1)
{
var clearedStage = StaticDataParser.Instance.GetStageData(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();
response = CompleteStage(user, req.StageId);
}
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();
if (rewardData.rewards == null) return ret;
@@ -81,11 +93,28 @@ namespace nksrv.LobbyServer.Msgs.Stage
if (rewardData.user_exp != 0)
{
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)
{
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
// NOTE: Current Exp/Lv refers to after XP was added.
@@ -103,10 +132,6 @@ namespace nksrv.LobbyServer.Msgs.Stage
};
user.userPointData.ExperiencePoint = newXp;
if (newLevel > user.userPointData.UserLevel)
{
// TODO: Commander Level up reward
}
user.userPointData.UserLevel = newLevel;
}

View File

@@ -53,8 +53,6 @@ namespace nksrv.LobbyServer.Msgs.Stage
if (!found)
{
Console.WriteLine("chapter not found: " + key);
user.FieldInfo.Add(key, new FieldInfo());
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 });
}
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 } });
}
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
@@ -85,7 +87,7 @@ namespace nksrv.LobbyServer.Msgs.User
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.LastClearedNormalMainStageId = user.LastNormalStageCleared;
// Restore completed tutorials. GroupID is the first 4 digits of the Table ID.
foreach (var item in user.ClearedTutorialData)
{

View File

@@ -20,6 +20,8 @@ using Newtonsoft.Json.Linq;
using Swan;
using Google.Api;
using nksrv.StaticInfo;
using EmbedIO.WebApi;
using nksrv.LobbyServer.Msgs.Stage;
namespace nksrv
{
@@ -38,16 +40,202 @@ namespace nksrv
LobbyHandler.Init();
Logger.Info("Starting server");
new Thread(() =>
{
var server = CreateWebServer();
server.RunAsync();
}).Start();
using var server = CreateWebServer();
await server.RunAsync();
// cli interface
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()
{
var cert = new X509Certificate2(new X509Certificate(AppDomain.CurrentDomain.BaseDirectory + @"site.pfx"));
var server = new WebServer(o => o
.WithUrlPrefixes("https://*:443", "http://*:80")
.WithUrlPrefixes("https://*:443")
.WithMode(HttpListenerMode.EmbedIO).WithAutoLoadCertificate().WithCertificate(cert))
// First, we will configure our web server by adding Modules.
.WithLocalSessionManager()
@@ -60,7 +248,10 @@ namespace nksrv
.WithModule(new ActionModule("/media/", HttpVerbs.Any, HandleAsset))
.WithModule(new ActionModule("/PC/", HttpVerbs.Any, HandleAsset))
.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.
//server.StateChanged += (s, e) => $"WebServer New State - {e.NewState}".Info();
@@ -68,28 +259,59 @@ namespace nksrv
return server;
}
private static async Task HandleLauncherUI(IHttpContext ctx)
private static async Task HandleAdminRequest(IHttpContext context)
{
await ctx.SendStringAsync(@"<!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<h1>
<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>
</body>
</html>
", "text/html", Encoding.UTF8);
//check if user is logged in
if (context.Request.Cookies["token"] == null && context.Request.Url.PathAndQuery != "/api/login")
{
context.Redirect("/adminapi/login");
return;
}
//Check if authenticated correctly
User? currentUser = null;
if (context.Request.Url.PathAndQuery != "/api/login")
{
//verify token
foreach (var item in AdminApiController.AdminAuthTokens)
{
if (item.Key == context.Request.Cookies["token"].Value)
{
currentUser = item.Value;
}
}
}
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)
{
var theBytes = await PacketDecryption.DecryptOrReturnContentAsync(ctx);
@@ -169,7 +391,7 @@ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
public static string GetCachePathForPath(string path)
{
return AppDomain.CurrentDomain.BaseDirectory + "cache" + path;
return AppDomain.CurrentDomain.BaseDirectory + "cache/" + path;
}
private static async Task HandleAsset(IHttpContext ctx)
{

View File

@@ -2195,4 +2195,106 @@ message ReqAllClearEquipment {
message ResAllClearEquipment {
int64 Csn = 2;
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
/// </summary>
public string chapter_mod = "";
public string stage_type = "";
}
public class RewardTableRecord
{

View File

@@ -12,6 +12,7 @@ using ICSharpCode.SharpZipLib.Zip;
using Newtonsoft.Json.Linq;
using Swan.Parsers;
using Newtonsoft.Json;
using System.Drawing;
namespace nksrv.StaticInfo
{
@@ -20,14 +21,6 @@ namespace nksrv.StaticInfo
/// </summary>
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.
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()
@@ -38,7 +31,19 @@ namespace nksrv.StaticInfo
};
// Fields
public static StaticDataParser Instance;
private static StaticDataParser? _instance;
public static StaticDataParser Instance
{
get
{
if (_instance == null)
{
_instance = BuildAsync().Result;
}
return _instance;
}
}
private ZipFile MainZip;
private MemoryStream ZipStream;
private JArray questDataRecords;
@@ -49,16 +54,22 @@ namespace nksrv.StaticInfo
private JArray characterCostumeTable;
private JArray characterTable;
private JArray tutorialTable;
private JArray itemEquipTable;
static StaticDataParser()
public byte[] Sha256Hash;
public int Size;
static async Task<StaticDataParser> BuildAsync()
{
Logger.Info("Loading static data");
Load().Wait();
if (Instance == null) throw new Exception("static data load fail");
await Load();
Logger.Info("Parsing static data");
Instance.Parse().Wait();
await Instance.Parse();
return Instance;
}
public StaticDataParser(string filePath)
{
if (!File.Exists(filePath)) throw new ArgumentException("Static data file must exist", nameof(filePath));
@@ -73,6 +84,11 @@ namespace nksrv.StaticInfo
characterTable = new();
ZipStream = new();
tutorialTable = new();
itemEquipTable = new();
var rawBytes = File.ReadAllBytes(filePath);
Sha256Hash = SHA256.HashData(rawBytes);
Size = rawBytes.Length;
DecryptStaticDataAndLoadZip(filePath);
if (MainZip == null) throw new Exception("failed to read zip file");
@@ -82,7 +98,7 @@ namespace nksrv.StaticInfo
{
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);
byte[] decryptionKey = key2[0..16];
@@ -128,7 +144,7 @@ namespace nksrv.StaticInfo
dataMs.Position = 0;
// 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);
byte[] decryptionKey2 = key3[0..16];
@@ -142,8 +158,7 @@ namespace nksrv.StaticInfo
MainZip = new ZipFile(ZipStream, false);
}
public static void AesCtrTransform(
byte[] key, byte[] salt, Stream inputStream, Stream outputStream)
public static void AesCtrTransform(byte[] key, byte[] salt, Stream inputStream, Stream outputStream)
{
SymmetricAlgorithm aes = Aes.Create();
aes.Mode = CipherMode.ECB;
@@ -195,10 +210,10 @@ namespace nksrv.StaticInfo
}
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");
Instance = new(targetFile);
_instance = new(targetFile);
}
#endregion
@@ -229,6 +244,7 @@ namespace nksrv.StaticInfo
characterCostumeTable = await LoadZip("CharacterCostumeTable.json");
characterTable = await LoadZip("CharacterTable.json");
tutorialTable = await LoadZip("ContentsTutorialTable.json");
itemEquipTable = await LoadZip("ItemEquipTable.json");
}
public MainQuestCompletionData? GetMainQuestForStageClearCondition(int stage)
@@ -303,7 +319,13 @@ namespace nksrv.StaticInfo
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 prevValue = 0;
@@ -328,10 +350,10 @@ namespace nksrv.StaticInfo
}
else
{
return prevLevel;
return (prevLevel, prevValue);
}
}
return -1;
return (-1, -1);
}
public int GetNormalChapterNumberFromFieldName(string field)
{
@@ -403,5 +425,45 @@ namespace nksrv.StaticInfo
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.Collections.Generic;
using System.Linq;
@@ -11,9 +12,11 @@ namespace nksrv.Utils
public class AssetDownloadUtil
{
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)
{
var rawUrl = url.Replace("https://cloud.nikke-kr.com", "");
var rawUrl = url.Replace("https://cloud.nikke-kr.com/", "");
string targetFile = Program.GetCachePathForPath(rawUrl);
var targetDir = Path.GetDirectoryName(targetFile);
if (targetDir == null)
@@ -27,12 +30,12 @@ namespace nksrv.Utils
{
Logger.Info("Download " + targetFile);
// TODO: Ip might change for cloud.nikke-kr.com
string @base = rawUrl.StartsWith("/prdenv") ? "prdenv" : "media";
if (rawUrl.StartsWith("/PC"))
@base = "PC";
if (CloudIp == null)
{
CloudIp = await GetCloudIpAsync();
}
var requestUri = new Uri("https://35.190.17.65/" + @base + rawUrl);
var requestUri = new Uri("https://" + CloudIp + "/" + rawUrl);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.TryAddWithoutValidation("host", "cloud.nikke-kr.com");
using var response = await AssetDownloader.SendAsync(request);
@@ -48,11 +51,23 @@ namespace nksrv.Utils
}
else
{
Logger.Error("Failed to download " + url + " with status code " + response.StatusCode);
return null;
}
}
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 Newtonsoft.Json;
using nksrv.LobbyServer;
using nksrv.LobbyServer.Msgs.Stage;
using nksrv.StaticInfo;
using Swan.Logging;
using System;
@@ -78,6 +79,7 @@ namespace nksrv.Utils
public string Nickname = "SomePlayer";
public int ProfileIconId = 39900;
public bool ProfileIconIsPrism = false;
public bool IsAdmin = false;
// Game data
public List<string> CompletedScenarios = [];
@@ -134,10 +136,28 @@ namespace nksrv.Utils
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 int DbVersion = 0;
public int DbVersion = 2;
public List<User> Users = [];
public List<AccessToken> LauncherAccessTokens = [];
@@ -187,6 +207,20 @@ namespace nksrv.Utils
}
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();
}
else

View File

@@ -1,4 +1,9 @@

using nksrv.StaticInfo;
using Swan.Logging;
using System.Reflection;
namespace nksrv.Utils
{
public class NetUtils
@@ -17,5 +22,61 @@ namespace nksrv.Utils
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>
<PackageReference Include="ASodium" Version="0.6.1" />
<PackageReference Include="DnsClient" Version="1.8.0" />
<PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="Google.Api.CommonProtos" Version="2.15.0" />
<PackageReference Include="Google.Protobuf.Tools" Version="3.27.1" />
@@ -29,8 +30,14 @@
</ItemGroup>
<ItemGroup>
<None Update="gameconfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="site.pfx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="www\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</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>