From adfaef2b1acf865ab9067751562499c799ad97e3 Mon Sep 17 00:00:00 2001 From: Sylvie Nightshade Date: Tue, 29 Apr 2025 04:11:49 +0100 Subject: [PATCH] Initial commit --- .gitignore | 330 ++++++++++++++++++++++++++++++ Kyubey.sln | 16 ++ Kyubey/Database/JewelDbContext.cs | 17 ++ Kyubey/Database/Score.cs | 30 +++ Kyubey/Database/User.cs | 9 + Kyubey/Interop/JewelryMode.cs | 8 + Kyubey/Kyubey.csproj | 15 ++ Kyubey/Program.cs | 27 +++ Kyubey/ServerApiController.cs | 37 ++++ Kyubey/ServiceController.cs | 198 ++++++++++++++++++ Kyubey/Utils.cs | 24 +++ README.md | 11 + 12 files changed, 722 insertions(+) create mode 100644 .gitignore create mode 100644 Kyubey.sln create mode 100644 Kyubey/Database/JewelDbContext.cs create mode 100644 Kyubey/Database/Score.cs create mode 100644 Kyubey/Database/User.cs create mode 100644 Kyubey/Interop/JewelryMode.cs create mode 100644 Kyubey/Kyubey.csproj create mode 100644 Kyubey/Program.cs create mode 100644 Kyubey/ServerApiController.cs create mode 100644 Kyubey/ServiceController.cs create mode 100644 Kyubey/Utils.cs create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7023fb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,330 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ \ No newline at end of file diff --git a/Kyubey.sln b/Kyubey.sln new file mode 100644 index 0000000..8d90f69 --- /dev/null +++ b/Kyubey.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyubey", "Kyubey\Kyubey.csproj", "{4DCAE4EA-12E7-44D6-9C0A-F4F9773D20B9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4DCAE4EA-12E7-44D6-9C0A-F4F9773D20B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DCAE4EA-12E7-44D6-9C0A-F4F9773D20B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DCAE4EA-12E7-44D6-9C0A-F4F9773D20B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DCAE4EA-12E7-44D6-9C0A-F4F9773D20B9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Kyubey/Database/JewelDbContext.cs b/Kyubey/Database/JewelDbContext.cs new file mode 100644 index 0000000..d503b1a --- /dev/null +++ b/Kyubey/Database/JewelDbContext.cs @@ -0,0 +1,17 @@ +using LiteDB; + +namespace Kyubey.Database; + +public class JewelDbContext +{ + private static LiteDatabase _db = null!; + public static ILiteCollection Scores = null!; + public static ILiteCollection Users = null!; + + public static void Init() + { + _db = new LiteDatabase("kyubey.db"); + Scores = _db.GetCollection("scores"); + Users = _db.GetCollection("users"); + } +} \ No newline at end of file diff --git a/Kyubey/Database/Score.cs b/Kyubey/Database/Score.cs new file mode 100644 index 0000000..a2a2e82 --- /dev/null +++ b/Kyubey/Database/Score.cs @@ -0,0 +1,30 @@ +using Kyubey.Interop; +// ReSharper disable PropertyCanBeMadeInitOnly.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace Kyubey.Database; + +public record Score +{ + public required string Id { get; set; } + public required User User { get; set; } + public required JewelryMode GameMode { get; set; } + public required DateTime Timestamp { get; set; } + public required uint Level { get; set; } + public required uint Points { get; set; } + public required uint Time { get; set; } + public required uint Jewels { get; set; } + public required int Class { get; set; } + // base64'd binary data + public required string Replay { get; set; } + + public override string ToString() + { + var timestampChunk = Timestamp.ToString("dd MMM yyyy HH:mm tt"); + var utcOffset = Timestamp.ToString("zz"); + var formattedTimestamp = $"{timestampChunk} UTC{utcOffset}"; + + // unsure on if this is actually where class goes but it's the only logical place it could go + return $"{Id}\n\n{User.RankingName}\n{Points}\n{Class}\n{Level}\n{Jewels}\n{Time}\n{formattedTimestamp}\na\n"; + } +} \ No newline at end of file diff --git a/Kyubey/Database/User.cs b/Kyubey/Database/User.cs new file mode 100644 index 0000000..7e145bf --- /dev/null +++ b/Kyubey/Database/User.cs @@ -0,0 +1,9 @@ +namespace Kyubey.Database; + +public class User +{ + public string Username { get; set; } = null!; + // argon2 hashed even though the game sends passwords in plain-text over the querystring + public string Password { get; set; } = null!; + public string RankingName { get; set; } = null!; +} \ No newline at end of file diff --git a/Kyubey/Interop/JewelryMode.cs b/Kyubey/Interop/JewelryMode.cs new file mode 100644 index 0000000..a293d8f --- /dev/null +++ b/Kyubey/Interop/JewelryMode.cs @@ -0,0 +1,8 @@ +namespace Kyubey.Interop; + +public enum JewelryMode +{ + Normal = 0, + Hard = 1, + Death = 2 +} \ No newline at end of file diff --git a/Kyubey/Kyubey.csproj b/Kyubey/Kyubey.csproj new file mode 100644 index 0000000..59d60f7 --- /dev/null +++ b/Kyubey/Kyubey.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/Kyubey/Program.cs b/Kyubey/Program.cs new file mode 100644 index 0000000..7eb852c --- /dev/null +++ b/Kyubey/Program.cs @@ -0,0 +1,27 @@ +using EmbedIO; +using EmbedIO.WebApi; +using Kyubey.Database; + +namespace Kyubey; + +class Program +{ + static async Task Main(string[] args) + { + JewelDbContext.Init(); + var server = new WebServer(e => + e.WithUrlPrefix("http://hg.arika.co.jp:8081") + .WithMode(HttpListenerMode.EmbedIO)) + .WithWebApi("/JM_test/service", m => + { + m.OnHttpException = (ctx, ex) => ExceptionHandler.EmptyResponse(ctx, (ex as Exception)!); + m.WithController(); + }) + .WithWebApi("/api", m => + { + m.OnHttpException = (ctx, ex) => ExceptionHandler.EmptyResponse(ctx, (ex as Exception)!); + m.WithController(); + }); + await server.RunAsync(); + } +} \ No newline at end of file diff --git a/Kyubey/ServerApiController.cs b/Kyubey/ServerApiController.cs new file mode 100644 index 0000000..8da67e1 --- /dev/null +++ b/Kyubey/ServerApiController.cs @@ -0,0 +1,37 @@ +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Kyubey.Database; + +namespace Kyubey; + +public class ServerApiController : WebApiController +{ + public record ApiCreateUserRequestData + { + public required string Username { get; set; } + public required string Password { get; set; } + public required string RankingName { get; set; } + } + + [Route(HttpVerbs.Post, "/create_user")] + public async Task Signup([JsonData] ApiCreateUserRequestData data) + { + if (JewelDbContext.Users.Exists(e => e.Username == data.Username || e.RankingName == data.RankingName)) + { + throw HttpException.BadRequest("Username and rankingname must be unique"); + } + + var hashedPassword = Utils.ComputeHash(data.Password); + + var userObject = new User() + { + Username = data.Username, + Password = hashedPassword, + RankingName = data.RankingName + }; + + var id = JewelDbContext.Users.Insert(userObject).AsObjectId; + return $"ok; id={id}"; + } +} \ No newline at end of file diff --git a/Kyubey/ServiceController.cs b/Kyubey/ServiceController.cs new file mode 100644 index 0000000..e549143 --- /dev/null +++ b/Kyubey/ServiceController.cs @@ -0,0 +1,198 @@ +using System.Text; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Kyubey.Database; +using Kyubey.Interop; + +namespace Kyubey; + +public class ServiceController : WebApiController +{ + [Route(HttpVerbs.Get, "/GameEntry")] + public async Task GameEntry() + { + var maybeUsername = Request.QueryString.Get("id"); + var maybePassword = Request.QueryString.Get("pass"); + var maybeVersion = Request.QueryString.Get("ver"); + + if (maybeUsername is not { } username || + maybePassword is not { } password || + maybeVersion is not "1.32") + { + throw HttpException.BadRequest(); + } + + var hashedPass = Utils.ComputeHash(password); + + var loginUser = JewelDbContext.Users.FindOne(e => e.Username == username && e.Password == hashedPass); + var response = loginUser is not null ? "0" : "1"; + var buf = Encoding.ASCII.GetBytes(response); + + Response.ContentType = "text/plain"; + Response.ContentEncoding = Encoding.ASCII; + Response.ContentLength64 = buf.Length; + + await Response.OutputStream.WriteAsync(buf, 0, buf.Length); + } + + [Route(HttpVerbs.Get, "/GetName")] + public async Task GetName() + { + var maybeId = Request.QueryString.Get("id"); + if (maybeId is not { } id) + { + throw HttpException.BadRequest(); + } + + var rankingUser = JewelDbContext.Users.FindOne(e => e.Username == id); + if (rankingUser is null) throw HttpException.NotFound("No such userid"); + + var response = rankingUser.RankingName; + var buf = Encoding.ASCII.GetBytes(response); + + Response.ContentType = "text/plain"; + Response.ContentEncoding = Encoding.ASCII; + Response.ContentLength64 = buf.Length; + + await Response.OutputStream.WriteAsync(buf, 0, buf.Length); + } + + [Route(HttpVerbs.Get, "/GetMessage")] + public async Task GetMessage() + { + var responses = new[] + { + "jewelry master lives, but why", + "did you know this game sends your password in cleartext? it's hashed server-side though!", + "this is not an entirely serious project", + "powered by IDA - The Interactive Disassembler", + $"powered by Microsoft .NET version {Environment.Version}", + "powered by anime music", + "greetz 2 mihara, your EULA can't stop me", + "did you know i can make this text say anything i want", + "i wonder how many of these scrolling messages there are" + }; + + var responseIdx = Random.Shared.Next(responses.Length); + var response = $"the server says: {responses[responseIdx]}"; + var buf = Encoding.ASCII.GetBytes(response); + + Response.ContentType = "text/plain"; + Response.ContentEncoding = Encoding.ASCII; + Response.ContentLength64 = buf.Length; + + await Response.OutputStream.WriteAsync(buf, 0, buf.Length); + } + + [Route(HttpVerbs.Get, "/GetRanking")] + public async Task GetRanking() + { + // view = -1 means top 10, 0 and above mean pages in the global ranking + var view = int.Parse(Request.QueryString.Get("view")!); + var mode = (JewelryMode)int.Parse(Request.QueryString.Get("mode")!); + var userId = Request.QueryString.Get("id"); + + var response = ""; + + if (userId is not null && view == -1 || userId is null) // ?????? this game makes no sense dude + { + var factor = view == -1 ? 0 : view; + Console.WriteLine($"factor={factor}"); + + var globalRanking = JewelDbContext.Scores + .Find(e => e.GameMode == mode) + .OrderByDescending(e => e.Points) + .Skip(factor*view) + .Take(10) + .ToList(); + + if (globalRanking.Any()) + { + response = $"{factor}\n"; + } + + foreach (var score in globalRanking) response += score.ToString(); + } else // personal ranking + { + var personalRanking = JewelDbContext.Scores + .Find(e => e.User.Username == userId && e.GameMode == mode) + .OrderByDescending(e => e.Points) + .Take(10) + .ToList(); + + if (personalRanking.Any()) + { + response = "0\n"; + } + + foreach (var score in personalRanking) response += score.ToString(); + } + + var buf = Encoding.ASCII.GetBytes(response); + Response.ContentType = "text/plain"; + Response.ContentEncoding = Encoding.ASCII; + Response.ContentLength64 = buf.Length; + await Response.OutputStream.WriteAsync(buf, 0, buf.Length); + } + + [Route(HttpVerbs.Post, "/ScoreEntry")] + public async Task ScoreEntry() + { + var id = Request.QueryString.Get("id")!; + var mode = (JewelryMode)int.Parse(Request.QueryString.Get("mode")!); + var score = uint.Parse(Request.QueryString.Get("score")!); + var jewel = uint.Parse(Request.QueryString.Get("jewel")!); + var rank = int.Parse(Request.QueryString.Get("class")!); // probably title + var time = uint.Parse(Request.QueryString.Get("time")!); + var level = uint.Parse(Request.QueryString.Get("level")!); + + var maybeUser = JewelDbContext.Users.FindOne(e => e.Username == id); + if (maybeUser is not {} user) + { + throw HttpException.NotFound("No such userid"); + } + + // TODO: add compression to replay data + var rawReplay = await HttpContext.GetRequestBodyAsByteArrayAsync(); + var replay = Convert.ToBase64String(rawReplay); + + var scoreRecord = new Score() + { + Id = Utils.GenerateCheapGuid(), + User = user, + GameMode = mode, + Points = score, + Class = rank, + Time = time, + Jewels = jewel, + Level = level, + Replay = replay, + Timestamp = DateTime.Now + }; + + JewelDbContext.Scores.Insert(scoreRecord); + JewelDbContext.Scores.EnsureIndex(e => e.Points); + + Response.ContentType = "text/plain"; + Response.ContentEncoding = Encoding.ASCII; + } + + [Route(HttpVerbs.Get, "/GetReplay")] + public async Task GetReplay() + { + Response.ContentType = "application/octet-stream"; + + var replayId = Request.QueryString.Get("id")!; + var score = JewelDbContext.Scores.FindOne(e => e.Id == replayId); + if (score is null) + { + throw HttpException.NotFound("No such replay"); + } + + // deconvert the replay + var replayBytes = Convert.FromBase64String(score.Replay); + // fire it down the byte hole + await Response.OutputStream.WriteAsync(replayBytes, 0, replayBytes.Length); + } +} \ No newline at end of file diff --git a/Kyubey/Utils.cs b/Kyubey/Utils.cs new file mode 100644 index 0000000..bd835dc --- /dev/null +++ b/Kyubey/Utils.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Kyubey; + +public class Utils +{ + public static string ComputeHash(string payload) + { + using var sha = SHA256.Create(); + var hashedBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexString(hashedBytes); + } + + // Not really ideal but using a full System.Guid overflows a buffer within the game which breaks replay downloading + // This game is wonderful and working on it is even more so + public static string GenerateCheapGuid() + { + var rng = new Random(); + var bytes = new byte[8]; + rng.NextBytes(bytes); + return Convert.ToHexStringLower(bytes); + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..be68db1 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Kyubey +Server reimplementation for 2006 Arika video game [Jewelry Master](https://tetris.wiki/Jewelry_Master). The game never came out of beta and now you too can see why. + +## Dependencies +Nothing but a standard .NET install + +## Why? +I (Lappland) got really bored one day and decided to reverse this game's netcode + +## Why the name? +Jewelry, soul gems, something like that?