diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitignore b/.gitignore index 0552dd4..0d78289 100644 --- a/.gitignore +++ b/.gitignore @@ -1,137 +1,4 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* -.DS_Store +# Godot 4+ specific ignores +.godot/ +/android/ +*.DS_Store diff --git a/game-example/AsyncHttpRequest.cs b/game-example/AsyncHttpRequest.cs new file mode 100644 index 0000000..e7a34f4 --- /dev/null +++ b/game-example/AsyncHttpRequest.cs @@ -0,0 +1,56 @@ +using Godot; +using Godot.Collections; +using System; +using System.Threading.Tasks; + +public struct HttpResponse { + public Error Error { get; init; } + public HttpRequest.Result Result { get; init; } + public int Status { get; init; } + public Dictionary Headers { get; init; } + public byte[] Body { get; init; } +} + +public partial class AsyncHttpRequest : HttpRequest { + //adapted from https://github.com/Swarkin/Godot-AwaitableHTTPRequest/tree/main + [Signal] + public delegate void RequestFinishedEventHandler(); + private bool isRequesting = false; + public bool IsRequesting { get => isRequesting; } + + public async Task AsyncRequest(string url, string[] customHeaders = null, HttpClient.Method method = HttpClient.Method.Get, string requestData = "") { + if (IsRequesting) { + GD.PushWarning("AsyncHttpRequest is busy performing a request."); + return new HttpResponse() { Error = Error.Busy }; + } + + isRequesting = true; + + //FIXME: needs to be called from main thread! + Error err = Request(url, customHeaders, method, requestData); + if (err != Error.Ok) { + return new HttpResponse() { Error = err }; + } + + Variant[] result = await ToSignal(this, SignalName.RequestCompleted); + isRequesting = false; + EmitSignal(SignalName.RequestFinished); + + return new HttpResponse() { + Error = Error.Ok, + Result = (HttpRequest.Result) result[0].AsInt64(), + Status = result[1].AsInt32(), + Headers = HeadersToDict(result[2].AsStringArray()), + Body = result[3].AsByteArray() + }; + } + + private static Dictionary HeadersToDict(string[] headers) { + Dictionary ret = new(); + foreach (string s in headers) { + string[] split = s.Split(":"); + ret[split[0]] = split[1].StripEdges(); + } + return ret; + } +} diff --git a/game-example/AsyncHttpRequest.cs.uid b/game-example/AsyncHttpRequest.cs.uid new file mode 100644 index 0000000..31c712a --- /dev/null +++ b/game-example/AsyncHttpRequest.cs.uid @@ -0,0 +1 @@ +uid://c26saxwqrxkyg diff --git a/game-example/Main.cs b/game-example/Main.cs new file mode 100644 index 0000000..7b7482b --- /dev/null +++ b/game-example/Main.cs @@ -0,0 +1,30 @@ +using Godot; +using System; +using System.Threading.Tasks; + +public partial class Main : Control { + [Export] + public LineEdit NameField; + [Export] + public OAuth OAuth; + [Export] + public Label TokenLabel; + + // Called when the node enters the scene tree for the first time. + public override void _Ready() { + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) { + } + + public void _OnLoginPressed() { + string handle = NameField.Text; + Task t = new(async () => await OAuth.Authorize(handle)); + t.Start(); + } + + public void _OnTokenReceived() { + TokenLabel.Text = OAuth.Token; + } +} diff --git a/game-example/Main.cs.uid b/game-example/Main.cs.uid new file mode 100644 index 0000000..cc5e301 --- /dev/null +++ b/game-example/Main.cs.uid @@ -0,0 +1 @@ +uid://py2tglj0jfff diff --git a/game-example/OAuth.cs b/game-example/OAuth.cs new file mode 100644 index 0000000..fcc2946 --- /dev/null +++ b/game-example/OAuth.cs @@ -0,0 +1,201 @@ +using Godot; +using System; +using System.Text.Json; +using System.Threading.Tasks; + +[GlobalClass] +public partial class OAuth : Control { + //from https://github.com/ACB-prgm/OAuth2-Tutorial/blob/master/OAuth2/OAuth2.gd + public const int Port = 2797; //goroawase for "tsunagite" + public const string Binding = "127.0.0.1"; + + public const string AuthUrl = "http://127.0.0.1:8080/oauth/authorize"; + public const string TokenUrl = "http://127.0.0.1:8080/oauth/token"; + public const string SaveDir = "user://token"; + public const string SavePath = $"{SaveDir}/token.dat"; + + public TcpServer RedirectServer = new(); + public string RedirectUri = $"http://{Binding}:{Port}"; + + public string ClientId = "client_XTcqz8gXODW6yDv-lf9KJg"; + + public string Token; + private string refreshToken; + + public AsyncHttpRequest Request; + + [Signal] + public delegate void TokenReceivedEventHandler(); + + // Called when the node enters the scene tree for the first time. + public override void _Ready() { + Request = new AsyncHttpRequest(); + CallDeferred(MethodName.AddChild, Request); + SetProcess(false); + } + + // Called every frame. 'delta' is the elapsed time since the previous frame. + public override void _Process(double delta) { + if (RedirectServer.IsConnectionAvailable()) { + StreamPeerTcp connection = RedirectServer.TakeConnection(); + string request = connection.GetString(connection.GetAvailableBytes()); + if (!String.IsNullOrEmpty(request)) { + SetProcess(false); + //TODO: this seems sus, will need to check for quickslice + string authCode = request.Split("&scope")[0].Split("=")[1]; + GetTokenFromAuth(authCode); + + connection.PutData("HTTP/1.1 200\r\n".ToAsciiBuffer()); + //TODO: response page here + connection.PutData("".ToAsciiBuffer()); + RedirectServer.Stop(); + } + } + } + + public async Task Authorize(string handle) { + LoadTokens(); + + if(!await IsTokenValid()) { + if (!await RefreshTokens()) { + GetAuthCode(handle); + } + } + } + + private void GetAuthCode(string handle) { + SetProcess(true); + Error err = RedirectServer.Listen(Port, Binding); + + string[] bodyParts = [ + $"client_id={ClientId}", + $"redirect_uri={RedirectUri}", + $"response_type=code", + //TODO: code challenge? + $"login_hint={handle}", + $"scope=atproto repo:dev.tsunagite.game repo:dev.tsunagite.song repo:dev.tsunagite.chart repo:dev.tsunagite.difficulty repo:dev.tsunagite.score" + ]; + string url = $"{AuthUrl}?{bodyParts.Join("&")}"; + + OS.ShellOpen(url); + } + + //TODO: DPoP + private async void GetTokenFromAuth(string authCode) { + GD.Print("Requesting token from auth"); + string[] headers = [ + "Content-Type: application/x-www-form-urlencoded" + ]; + string[] bodyParts = [ + $"code={authCode}", + $"client_id={ClientId}", + $"redirect_uri={RedirectUri}", + "grant_type=authorization_code" + ]; + + HttpResponse res = await Request.AsyncRequest(TokenUrl, headers, HttpClient.Method.Post, bodyParts.Join("&")); + + if (res.Error != Error.Ok) { + GD.PushError($"HTTP error {res.Error} occured while trying to obtain auth token"); + } else { + JsonDocument json = JsonDocument.Parse(res.Body.GetStringFromUtf8()); + Token = json.RootElement.GetProperty("access_token").GetString(); + refreshToken = json.RootElement.GetProperty("refresh_token").GetString(); + SaveTokens(); + EmitSignal(SignalName.TokenReceived); + GD.Print("Token obtained!"); + } + } + + private async Task RefreshTokens() { + GD.Print("Refreshing tokens"); + string[] headers = [ + "Content-Type: application/x-www-form-urlencoded" + ]; + string[] bodyParts = [ + $"client_id={ClientId}", + $"refresh_token={refreshToken}", + "grant_type=refresh_token" + ]; + + HttpResponse res = await Request.AsyncRequest(TokenUrl, headers, HttpClient.Method.Post, bodyParts.Join("&")); + + if (res.Error != Error.Ok) { + GD.PushError($"HTTP error {res.Error} occured while trying to refresh auth token"); + } else { + JsonDocument json = JsonDocument.Parse(res.Body.GetStringFromUtf8()); + if (json.RootElement.TryGetProperty("access_token", out JsonElement value)) { + Token = value.GetString(); + SaveTokens(); + EmitSignal(SignalName.TokenReceived); + GD.Print("Tokens refreshed!"); + return true; + } + } + GD.Print("Tokens not refreshed."); + return false; + } + + private async Task IsTokenValid() { + GD.Print("Checking if tokens are valid"); + if (string.IsNullOrEmpty(Token)) { + GD.Print("No token, delaying to check later"); + await ToSignal(GetTree().CreateTimer(0.001), Timer.SignalName.Timeout); + return false; + } + + string[] headers = [ + "Content-Type: application/x-www-form-urlencoded" + ]; + + string body = $"access_token={Token}"; + + HttpResponse res = await Request.AsyncRequest(TokenUrl+"info", headers, HttpClient.Method.Post, body); + + if (res.Error != Error.Ok) { + GD.PushError($"HTTP error {res.Error} occured while trying to check auth token validity"); + } else { + JsonDocument json = JsonDocument.Parse(res.Body.GetStringFromUtf8()); + if (json.RootElement.TryGetProperty("expires_in", out JsonElement value)) { + if (value.GetInt64() > 0) { + EmitSignal(SignalName.TokenReceived); + GD.Print("Tokens are valid!"); + return true; + } + } + } + GD.Print("Tokens are not valid!"); + return false; + } + + private void SaveTokens() { + if (!DirAccess.DirExistsAbsolute(SaveDir)) { + DirAccess.MakeDirRecursiveAbsolute(SaveDir); + } + + //IMPL NOTE: Use a more secure encryption in production! This is just for demonstration! + FileAccess file = FileAccess.OpenEncryptedWithPass(SavePath, FileAccess.ModeFlags.Write, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to save tokens"); + } else { + string json = $"{{ \"token\": \"{Token}\",\"refresh_token\": \"{refreshToken}\" }}"; + file.StoreString(json); + file.Close(); + } + } + + private void LoadTokens() { + if (FileAccess.FileExists(SavePath)) { + //IMPL NOTE: Use a more secure encryption in production! This is just for demonstration! + FileAccess file = FileAccess.OpenEncryptedWithPass(SavePath, FileAccess.ModeFlags.Read, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to load tokens"); + } else { + JsonDocument json = JsonDocument.Parse(file.GetAsText()); + Token = json.RootElement.GetProperty("token").GetString(); + refreshToken = json.RootElement.GetProperty("refresh_token").GetString(); + } + } + } + +} diff --git a/game-example/OAuth.cs.uid b/game-example/OAuth.cs.uid new file mode 100644 index 0000000..e649b82 --- /dev/null +++ b/game-example/OAuth.cs.uid @@ -0,0 +1 @@ +uid://bks2wtkcum06m diff --git a/game-example/Tsunagite Game Example.csproj b/game-example/Tsunagite Game Example.csproj new file mode 100644 index 0000000..ecd60e8 --- /dev/null +++ b/game-example/Tsunagite Game Example.csproj @@ -0,0 +1,13 @@ + + + net8.0 + net9.0 + true + TsunagiteGameExample + + + + + + + \ No newline at end of file diff --git a/game-example/Tsunagite Game Example.sln b/game-example/Tsunagite Game Example.sln new file mode 100644 index 0000000..9f08c6f --- /dev/null +++ b/game-example/Tsunagite Game Example.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tsunagite Game Example", "Tsunagite Game Example.csproj", "{703CA62E-2D16-4DA4-8A31-A165566C8C62}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {703CA62E-2D16-4DA4-8A31-A165566C8C62}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/game-example/icon.svg b/game-example/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/game-example/icon.svg @@ -0,0 +1 @@ + diff --git a/game-example/icon.svg.import b/game-example/icon.svg.import new file mode 100644 index 0000000..9ed68f7 --- /dev/null +++ b/game-example/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blvh26jl6esx5" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/game-example/main.tscn b/game-example/main.tscn new file mode 100644 index 0000000..304a268 --- /dev/null +++ b/game-example/main.tscn @@ -0,0 +1,54 @@ +[gd_scene load_steps=3 format=3 uid="uid://dx00j4ujhfd5n"] + +[ext_resource type="Script" uid="uid://py2tglj0jfff" path="res://Main.cs" id="1_ig7tw"] +[ext_resource type="Script" uid="uid://bks2wtkcum06m" path="res://OAuth.cs" id="2_0xm2m"] + +[node name="Node2D" type="Control" node_paths=PackedStringArray("NameField", "OAuth", "TokenLabel")] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +script = ExtResource("1_ig7tw") +NameField = NodePath("VBoxContainer/Name Field") +OAuth = NodePath("OAuth") +TokenLabel = NodePath("VBoxContainer/Token Label") + +[node name="OAuth" type="Control" parent="."] +anchors_preset = 0 +offset_right = 40.0 +offset_bottom = 40.0 +script = ExtResource("2_0xm2m") +metadata/_custom_type_script = "uid://bks2wtkcum06m" + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -20.0 +offset_top = -20.0 +offset_right = 20.0 +offset_bottom = 20.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Name Field" type="LineEdit" parent="VBoxContainer"] +layout_mode = 2 +placeholder_text = "you.tsunagite.dev" + +[node name="Login Button" type="Button" parent="VBoxContainer"] +layout_mode = 2 +text = "Log in with ATProto" + +[node name="Token Label" type="Label" parent="VBoxContainer"] +layout_mode = 2 +horizontal_alignment = 1 + +[connection signal="TokenReceived" from="OAuth" to="." method="_OnTokenReceived"] +[connection signal="pressed" from="VBoxContainer/Login Button" to="." method="_OnLoginPressed"] diff --git a/game-example/project.godot b/game-example/project.godot new file mode 100644 index 0000000..d43e97d --- /dev/null +++ b/game-example/project.godot @@ -0,0 +1,25 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Tsunagite Game Example" +run/main_scene="uid://dx00j4ujhfd5n" +config/features=PackedStringArray("4.5", "C#", "GL Compatibility") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="Tsunagite Game Example" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/schema/game.json b/schema/game.json index 5cf2a22..57eb8f2 100644 --- a/schema/game.json +++ b/schema/game.json @@ -3,29 +3,6 @@ "id": "dev.tsunagite.game", "description": "Definitions related to games hosting leaderboards via Tunagite.", "defs": { - "indexable": { - "type": "object", - "description": "A named value with a numeric index for sorting.", - "properties": { - "id": { - "type": "string", - "description": "The internal ID for the value, limited to the RecordKey character set.", - "format": "record-key", - "minLength": 1, - "maxLength": 32 - }, - "name": { - "type": "ref", - "description": "The human-readable name of the value for displaying in UIs.", - "ref": "dev.tsunagite.translatable" - }, - "index": { - "type": "integer", - "description": "The numeric index used for sorting." - } - }, - "required": ["id", "name", "index"] - }, "enum": { "type": "object", "description": "A closed set of indexable named values.", @@ -47,7 +24,7 @@ "description": "The allowed values for this enum to take.", "items": { "type": "ref", - "ref": "#indexable" + "ref": "dev.tsunagite.types#indexable" } } }, @@ -175,8 +152,9 @@ "type": "array", "description": "The obtainable judgments during gameplay.", "items": { - "type": "string", - "description": "Can be tokens or raw strings." + "type": "ref", + "description": "Indices will be displayed in ascending order.", + "ref": "dev.tsunagite.types#indexable" } }, "scoreComponents": { diff --git a/schema/types.json b/schema/types.json index 7278377..cd7ee03 100644 --- a/schema/types.json +++ b/schema/types.json @@ -3,6 +3,29 @@ "id": "dev.tsunagite.types", "description": "Assistant data types for Tsunagite lexicons.", "defs": { + "indexable": { + "type": "object", + "description": "A named value with a numeric index for sorting.", + "properties": { + "id": { + "type": "string", + "description": "The internal ID for the value, limited to the RecordKey character set.", + "format": "record-key", + "minLength": 1, + "maxLength": 32 + }, + "name": { + "type": "ref", + "description": "The human-readable name of the value for displaying in UIs.", + "ref": "dev.tsunagite.translatable" + }, + "index": { + "type": "integer", + "description": "The numeric index used for sorting." + } + }, + "required": ["id", "name", "index"] + }, "typedRef": { "type": "object", "description": "A typed record reference that does not require a CID hash.",