diff --git a/game-example/Judgment.cs b/game-example/Judgment.cs new file mode 100644 index 0000000..6f4e38a --- /dev/null +++ b/game-example/Judgment.cs @@ -0,0 +1,7 @@ +public enum Judgment { + critPerfect, + perfect, + great, + good, + miss +} \ No newline at end of file diff --git a/game-example/Judgment.cs.uid b/game-example/Judgment.cs.uid new file mode 100644 index 0000000..33f8b8d --- /dev/null +++ b/game-example/Judgment.cs.uid @@ -0,0 +1 @@ +uid://d3ne8xilujsa6 diff --git a/game-example/Lamp.cs b/game-example/Lamp.cs new file mode 100644 index 0000000..032ac2d --- /dev/null +++ b/game-example/Lamp.cs @@ -0,0 +1,6 @@ +public enum Lamp { + ap, + fc, + pass, + fail +} \ No newline at end of file diff --git a/game-example/Lamp.cs.uid b/game-example/Lamp.cs.uid new file mode 100644 index 0000000..5400f51 --- /dev/null +++ b/game-example/Lamp.cs.uid @@ -0,0 +1 @@ +uid://j3ewe1ax7hvo diff --git a/game-example/Main.cs b/game-example/Main.cs index 7dfaea8..129ea0f 100644 --- a/game-example/Main.cs +++ b/game-example/Main.cs @@ -1,20 +1,19 @@ using Godot; using System; +using System.Collections.Generic; using System.Threading.Tasks; public partial class Main : Control { [Export] public LineEdit NameField; [Export] - public OAuth OAuth; + public TsunagiteHandler Handler; [Export] - public Label TokenLabel; + public Label ResultLabel; // Called when the node enters the scene tree for the first time. public override void _Ready() { - if (OAuth.Authorized) { - TokenLabel.Text = OAuth.Token; - } + Handler.Reauthorize(); } // Called every frame. 'delta' is the elapsed time since the previous frame. @@ -23,11 +22,15 @@ public partial class Main : Control { public void _OnLoginPressed() { string handle = NameField.Text; - OAuth.Authorize(handle); + Handler.Authorize(handle); } - public void _OnTokenReceived() { - TokenLabel.Text = OAuth.Token; + public void _OnHandleLoaded(string handle) { + NameField.Text = handle; + } + + public void _OnAuthComplete(bool authed) { + GD.Print($"Authed: {authed}"); } public void _OnDoThingPressed() { @@ -35,64 +38,23 @@ public partial class Main : Control { } public async Task DoThisPlease() { - // HttpRequest request = new(); - // AddChild(request); - // // string requestUrl = "http://127.0.0.1:8080/admin/graphql"; - // string requestUrl = "http://127.0.0.1:8080/graphql"; - // string[] headers = [ - // $"Authorization: Bearer {OAuth.Token}", - // "Accept: application/json", - // "Content-Type: application/json" - // ]; - // string req = """ - // mutation { - // createDevTsunagiteScore( - // rkey: "3mc4n5cne422d" - // input: { - // game: "at://lemmaeof.gay/dev.tsunagite.game/testGame", - // song: "at://lemmaeof.gay/dev.tsunagite.song/mirinTestSong", - // chart: "at://lemmaeof.gay/dev.tsunagite.chart/mirinTestSong-expert", - // judgments: { - // critPerfect: { - // total: 32 - // } - // perfect: { - // total: 16 - // early: 9 - // late: 7 - // } - // great: { - // total: 8 - // early: 5 - // late: 3 - // } - // good: { - // total: 4 - // early: 3 - // late: 1 - // } - // miss: { - // total: 2 - // } - // }, - // scoreComponents: { - // lamp: { - // value: "pass" - // } - // points: { - // value: 18000 - // } - // accuracy: { - // value: "85.1485" - // } - // } - // } - // ) - // } - // """; - // GD.Print(QDGQL.WrapQuery(req)); - // Error err = request.Request(requestUrl, headers, HttpClient.Method.Post, QDGQL.WrapQuery(req)); - // HttpResult res = new(await ToSignal(request, HttpRequest.SignalName.RequestCompleted)); - // GD.Print($"{res.Body}"); + TsunagiteScore score = new() { + Timestamp = DateTime.Now, + SongKey = "mirinTestSong", + ChartKey = "mirinTestSong-Expert", + Judgments = new JudgmentTotals() { + {Judgment.critPerfect, new JudgmentTotalSet() {Total = 36}}, + {Judgment.perfect, new JudgmentTotalSet() {Total = 14, Early = 7, Late = 7}}, + {Judgment.great, new JudgmentTotalSet() {Total = 10, Early = 6, Late = 3}}, + {Judgment.good, new JudgmentTotalSet() {Total = 2, Late = 2}} + }, + ScoreComponents = new Dictionary() { + {"lamp", new EnumComponent(Lamp.fc)}, + {"points", new PointsComponent(19000)}, + {"accuracy", new PercentageComponent(90.2235f, 4)} + } + }; + string res = await Handler.SubmitScore(score); + ResultLabel.Text = res; } } diff --git a/game-example/Tsunagite Game Example.csproj b/game-example/Tsunagite Game Example.csproj index 7cc8fc2..06ef7ed 100644 --- a/game-example/Tsunagite Game Example.csproj +++ b/game-example/Tsunagite Game Example.csproj @@ -9,6 +9,7 @@ - + + \ No newline at end of file diff --git a/game-example/TsunagiteHandler.cs b/game-example/TsunagiteHandler.cs new file mode 100644 index 0000000..dbf51f6 --- /dev/null +++ b/game-example/TsunagiteHandler.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Godot; + +public abstract partial class TsunagiteHandler: Control { + //Fill these in with your game data! Will be auto-assembled at submit time. + public const string OwnerHandle = "lemmaeof.gay"; + public const string GameId = "testGame"; + + [Signal] + public delegate void AuthCompleteEventHandler(bool successful); + [Signal] + public delegate void HandleLoadedEventHandler(string handle); + public abstract Task Authorize(string handle); + public abstract Task Reauthorize(); + public abstract Task SubmitScore(TsunagiteScore score); + public abstract bool IsAuthorized(); +} \ No newline at end of file diff --git a/game-example/TsunagiteHandler.cs.uid b/game-example/TsunagiteHandler.cs.uid new file mode 100644 index 0000000..99794dc --- /dev/null +++ b/game-example/TsunagiteHandler.cs.uid @@ -0,0 +1 @@ +uid://bobbdm4oma110 diff --git a/game-example/fishyflip/FishyFlipHandler.cs b/game-example/fishyflip/FishyFlipHandler.cs new file mode 100644 index 0000000..8b8d6bd --- /dev/null +++ b/game-example/fishyflip/FishyFlipHandler.cs @@ -0,0 +1,160 @@ +using System.Threading.Tasks; +using FishyFlip; +using FishyFlip.Models; +using Godot; +using System; +using Duende.IdentityModel.OidcClient.Browser; +using FishyFlip.Lexicon.Com.Atproto.Repo; + +[GlobalClass] +public partial class FishyFlipHandler : TsunagiteHandler { + public const string SaveDir = "user://token"; + public const string IdPath = $"{SaveDir}/id.dat"; + public const string SessionPath = $"{SaveDir}/session.dat"; + + private bool authorized; + private AuthSession session; + private HandleResolution handleInfo; + private ATProtocol proto; + + public override async Task Authorize(string handle) { + if (!LoadId()) { + handleInfo = await Handles.Resolve(handle); + SaveId(); + } + ATIdentifier id = ATIdentifier.Create(handleInfo.Did); + proto = GenerateProtocol(); + GodotBrowser browser = new(); + AddChild(browser); + string[] scopes = ["atproto", "transition:generic"]; + (string url, ATError error) = await proto.GenerateOAuth2AuthenticationUrlResultAsync("https://lemmaeof.gay/oauth-client-metadata.json", browser.RedirectUri, scopes, id); + if (error is not null) { + GD.PrintErr($"Error while generating url: {error}"); + return; + } + BrowserResult result = await browser.InvokeAsync(url); + if (result.IsError) { + GD.PrintErr($"Error while authenticating: {error}"); + } + GD.Print(result.Response); + (Session sess, error) = await proto.AuthenticateWithOAuth2CallbackResultAsync($"{browser.RedirectUri}?{result.Response}"); + if (error != null) { + GD.PrintErr($"Error while getting session: {error}"); + return; + } + (session, error) = await proto.RefreshAuthSessionResultAsync(); + if (error != null) { + GD.PrintErr($"Error while refreshing: {error}"); + return; + } + SaveSession(); + authorized = true; + EmitSignal(TsunagiteHandler.SignalName.AuthComplete, true); + } + + public override async Task Reauthorize() { + bool idExists = LoadId(); + bool sessionExists = LoadSession(); + if (!idExists || !sessionExists) { + GD.Print($"Missing info to refresh session - ID data exists {idExists}, session data exists {sessionExists}"); + authorized = false; + EmitSignal(TsunagiteHandler.SignalName.AuthComplete, false); + return; + } + proto = GenerateProtocol(); + (Session sess, ATError error) = await proto.AuthenticateWithOAuth2SessionResultAsync(session, "https://lemmaeof.gay/oauth-client-metadata.json"); + if (error != null) { + GD.PrintErr($"Error while getting session: {error}"); + return; + } + (session, error) = await proto.RefreshAuthSessionResultAsync(); + if (error != null) { + GD.PrintErr($"Error while refreshing: {error}"); + return; + } + SaveSession(); + authorized = true; + EmitSignal(TsunagiteHandler.SignalName.AuthComplete, true); + } + + public override async Task SubmitScore(TsunagiteScore score) { + ScoreWrapper wrapper = new(score); + (CreateRecordOutput res, ATError error) = await proto.CreateRecordAsync(ATIdentifier.Create(handleInfo.Did), "dev.tsunagite.score", wrapper, TimestampId.EncodeTid(score.Timestamp)); + if (res != null) { + GD.Print(res.Cid); + return res.Uri.ToString(); + } else { + GD.PrintErr($"Error creating record: {error}"); + } + return ""; + } + + public override bool IsAuthorized() { + return authorized; + } + + private ATProtocol GenerateProtocol() { + return new ATProtocolBuilder() + .WithInstanceUrl(new Uri(handleInfo.PdsAddress)) + .WithPlcMirror(new Uri("https://plc.wtf")) + .Build(); + } + + private void SaveId() { + 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(IdPath, FileAccess.ModeFlags.Write, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to save ID info"); + } else { + file.StoreString(handleInfo.ToString()); + file.Close(); + } + } + + private bool LoadId() { + if (FileAccess.FileExists(IdPath)) { + //IMPL NOTE: Use a more secure encryption in production! This is just for demonstration! + FileAccess file = FileAccess.OpenEncryptedWithPass(IdPath, FileAccess.ModeFlags.Read, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to load ID info"); + } else { + handleInfo = HandleResolution.FromString(file.GetAsText()); + EmitSignal(TsunagiteHandler.SignalName.HandleLoaded, handleInfo.Handle); + return true; + } + } + return false; + } + + private void SaveSession() { + 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(SessionPath, FileAccess.ModeFlags.Write, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to save auth session"); + } else { + file.StoreString(session.ToString()); + file.Close(); + } + + } + + private bool LoadSession() { + if (FileAccess.FileExists(SessionPath)) { + //IMPL NOTE: Use a more secure encryption in production! This is just for demonstration! + FileAccess file = FileAccess.OpenEncryptedWithPass(SessionPath, FileAccess.ModeFlags.Read, "tsunagite"); + if (file.GetError() != Error.Ok) { + GD.PushError($"Encountered error {file.GetError()} while trying to load auth session"); + } else { + session = AuthSession.FromString(file.GetAsText()); + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/game-example/fishyflip/FishyFlipHandler.cs.uid b/game-example/fishyflip/FishyFlipHandler.cs.uid new file mode 100644 index 0000000..2517d72 --- /dev/null +++ b/game-example/fishyflip/FishyFlipHandler.cs.uid @@ -0,0 +1 @@ +uid://ctgotmluglc5k diff --git a/game-example/fishyflip/GodotBrowser.cs b/game-example/fishyflip/GodotBrowser.cs new file mode 100644 index 0000000..2a271c9 --- /dev/null +++ b/game-example/fishyflip/GodotBrowser.cs @@ -0,0 +1,49 @@ +using Godot; +using Duende.IdentityModel.OidcClient.Browser; +using System.Threading.Tasks; +using System.Threading; +using System; + +public partial class GodotBrowser : Node, IBrowser { + [Signal] + public delegate void CallbackReceivedEventHandler(string response); + + public TcpServer RedirectServer = new(); + public string RedirectUri = "http://127.0.0.1:2797/"; + + public override void _Ready() { + SetProcess(false); + } + 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); + + connection.PutData("HTTP/1.1 200\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Length: 65\r\nConnection: close\r\n\r\n".ToAsciiBuffer()); + //TODO: proper response page here? + connection.PutData("Authentication complete!".ToAsciiBuffer()); + connection.DisconnectFromHost(); + RedirectServer.Stop(); + string authCode = request.Split(" HTTP/1.1")[0].Split("?")[1]; + EmitSignal(SignalName.CallbackReceived, authCode); + } + } + } + public Task InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) { + throw new System.NotImplementedException(); + } + + public async Task InvokeAsync(string url, CancellationToken cancellationToken = default) { + OS.ShellOpen(url); + SetProcess(true); + RedirectServer.Listen(2797); + string callback = (string)(await ToSignal(this, SignalName.CallbackReceived))[0]; + if (string.IsNullOrWhiteSpace(callback)) { + return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Empty response." }; + } else { + return new BrowserResult { ResultType = BrowserResultType.Success, Response = callback }; + } + } +} \ No newline at end of file diff --git a/game-example/fishyflip/GodotBrowser.cs.uid b/game-example/fishyflip/GodotBrowser.cs.uid new file mode 100644 index 0000000..63984b6 --- /dev/null +++ b/game-example/fishyflip/GodotBrowser.cs.uid @@ -0,0 +1 @@ +uid://bosdfd8t6vpxu diff --git a/game-example/fishyflip/ScoreWrapper.cs b/game-example/fishyflip/ScoreWrapper.cs new file mode 100644 index 0000000..a18778a --- /dev/null +++ b/game-example/fishyflip/ScoreWrapper.cs @@ -0,0 +1,13 @@ +using FishyFlip; +using FishyFlip.Lexicon; +using PeterO.Cbor; + +public class ScoreWrapper(TsunagiteScore score) : ATObject { + public override string ToJson() { + return score.ToJson().ToJsonString(); + } + + public override CBORObject ToCBORObject() { + return score.ToCbor(); + } +} \ No newline at end of file diff --git a/game-example/fishyflip/ScoreWrapper.cs.uid b/game-example/fishyflip/ScoreWrapper.cs.uid new file mode 100644 index 0000000..d1df499 --- /dev/null +++ b/game-example/fishyflip/ScoreWrapper.cs.uid @@ -0,0 +1 @@ +uid://cc3t5nl82p3qd diff --git a/game-example/main.tscn b/game-example/main.tscn index 646e3d5..7b5d100 100644 --- a/game-example/main.tscn +++ b/game-example/main.tscn @@ -1,9 +1,9 @@ -[gd_scene load_steps=3 format=3 uid="uid://dx00j4ujhfd5n"] +[gd_scene 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"] +[ext_resource type="Script" uid="uid://ctgotmluglc5k" path="res://fishyflip/FishyFlipHandler.cs" id="2_0xm2m"] -[node name="Node2D" type="Control" node_paths=PackedStringArray("NameField", "OAuth", "TokenLabel")] +[node name="Node2D" type="Control" unique_id=1655432914 node_paths=PackedStringArray("NameField", "Handler", "ResultLabel")] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -14,25 +14,17 @@ 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") +Handler = NodePath("Handler") +ResultLabel = NodePath("VBoxContainer/Result Label") -[node name="OAuth" type="Control" parent="." node_paths=PackedStringArray("Request", "Timer")] +[node name="Handler" type="Control" parent="." unique_id=523132026] anchors_preset = 0 offset_right = 40.0 offset_bottom = 40.0 script = ExtResource("2_0xm2m") -Request = NodePath("HTTPRequest") -Timer = NodePath("Timer") -metadata/_custom_type_script = "uid://bks2wtkcum06m" +metadata/_custom_type_script = "uid://ctgotmluglc5k" -[node name="HTTPRequest" type="HTTPRequest" parent="OAuth"] - -[node name="Timer" type="Timer" parent="OAuth"] -wait_time = 0.05 -one_shot = true - -[node name="VBoxContainer" type="VBoxContainer" parent="."] +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=381839677] layout_mode = 1 anchors_preset = 8 anchor_left = 0.5 @@ -46,22 +38,23 @@ offset_bottom = 20.0 grow_horizontal = 2 grow_vertical = 2 -[node name="Name Field" type="LineEdit" parent="VBoxContainer"] +[node name="Name Field" type="LineEdit" parent="VBoxContainer" unique_id=230847250] layout_mode = 2 placeholder_text = "you.tsunagite.dev" -[node name="Login Button" type="Button" parent="VBoxContainer"] +[node name="Login Button" type="Button" parent="VBoxContainer" unique_id=644165583] layout_mode = 2 text = "Log in with ATProto" -[node name="Token Label" type="Label" parent="VBoxContainer"] +[node name="Result Label" type="Label" parent="VBoxContainer" unique_id=768867823] layout_mode = 2 horizontal_alignment = 1 -[node name="Button" type="Button" parent="VBoxContainer"] +[node name="Button" type="Button" parent="VBoxContainer" unique_id=1057476799] layout_mode = 2 text = "Do the thing" -[connection signal="TokenReceived" from="OAuth" to="." method="_OnTokenReceived"] +[connection signal="AuthComplete" from="Handler" to="." method="_OnAuthComplete"] +[connection signal="HandleLoaded" from="Handler" to="." method="_OnHandleLoaded"] [connection signal="pressed" from="VBoxContainer/Login Button" to="." method="_OnLoginPressed"] [connection signal="pressed" from="VBoxContainer/Button" to="." method="_OnDoThingPressed"] diff --git a/game-example/HttpResult.cs b/game-example/manual/HttpResult.cs similarity index 100% rename from game-example/HttpResult.cs rename to game-example/manual/HttpResult.cs diff --git a/game-example/HttpResult.cs.uid b/game-example/manual/HttpResult.cs.uid similarity index 100% rename from game-example/HttpResult.cs.uid rename to game-example/manual/HttpResult.cs.uid diff --git a/game-example/OAuth.cs b/game-example/manual/ManualHandler.cs similarity index 92% rename from game-example/OAuth.cs rename to game-example/manual/ManualHandler.cs index 25ca180..13218f4 100644 --- a/game-example/OAuth.cs +++ b/game-example/manual/ManualHandler.cs @@ -5,9 +5,10 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +//A sample of manually-handled OAuth. Unfinished. //TODO: uuuuuggggghhhhh this is gonna require PKCE/DPOP to go through PDSes directly waaaa [GlobalClass] -public partial class OAuth : Control { +public partial class ManualHandler : TsunagiteHandler { //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"; @@ -40,14 +41,10 @@ public partial class OAuth : Control { public HttpRequest Request; [Export] public Timer Timer; - - [Signal] - public delegate void TokenReceivedEventHandler(); // Called when the node enters the scene tree for the first time. public override void _Ready() { SetProcess(false); - InitialAuth(); } // Called every frame. 'delta' is the elapsed time since the previous frame. @@ -61,7 +58,7 @@ public partial class OAuth : Control { string authCode = request.Split(" HTTP/1.1")[0].Split("code=")[1]; GetTokenFromAuth(authCode); - connection.PutData("HTTP/1.1 200\r\n".ToAsciiBuffer()); + connection.PutData("HTTP/1.1 200\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Length: 65\r\nConnection: close\r\n\r\n".ToAsciiBuffer()); //TODO: response page here connection.PutData("Authentication complete!".ToAsciiBuffer()); RedirectServer.Stop(); @@ -69,7 +66,7 @@ public partial class OAuth : Control { } } - public async void InitialAuth() { + public override async Task Reauthorize() { LoadTokens(); if (string.IsNullOrEmpty(pds)) { @@ -86,7 +83,7 @@ public partial class OAuth : Control { Authorized = true; } - public async Task Authorize(string handle) { + public override async Task Authorize(string handle) { LoadTokens(); if (string.IsNullOrEmpty(pds) || string.IsNullOrEmpty(did)) { @@ -103,6 +100,14 @@ public partial class OAuth : Control { } } + public override async Task SubmitScore(TsunagiteScore score) { + throw new NotImplementedException(); + } + + public override bool IsAuthorized() { + return Authorized; + } + private void GetAuthCode(string handle) { SetProcess(true); Error err = RedirectServer.Listen(Port, Binding); @@ -161,7 +166,7 @@ public partial class OAuth : Control { refreshToken = json.RootElement.GetProperty("refresh_token").GetString(); int expiresIn = json.RootElement.GetProperty("expires_in").GetInt32(); SaveTokens(expiresIn); - EmitSignal(SignalName.TokenReceived); + EmitSignal(SignalName.AuthComplete, true); } } } @@ -192,7 +197,7 @@ public partial class OAuth : Control { Token = value.GetString(); int expiresIn = json.RootElement.GetProperty("expires_in").GetInt32(); SaveTokens(expiresIn); - EmitSignal(SignalName.TokenReceived); + EmitSignal(SignalName.AuthComplete, true); return true; } } diff --git a/game-example/OAuth.cs.uid b/game-example/manual/ManualHandler.cs.uid similarity index 100% rename from game-example/OAuth.cs.uid rename to game-example/manual/ManualHandler.cs.uid diff --git a/game-example/Pkce.cs b/game-example/manual/PkcePort.cs similarity index 100% rename from game-example/Pkce.cs rename to game-example/manual/PkcePort.cs diff --git a/game-example/Pkce.cs.uid b/game-example/manual/PkcePort.cs.uid similarity index 100% rename from game-example/Pkce.cs.uid rename to game-example/manual/PkcePort.cs.uid diff --git a/game-example/score/JudgmentTotals.cs b/game-example/score/JudgmentTotals.cs new file mode 100644 index 0000000..2f364f9 --- /dev/null +++ b/game-example/score/JudgmentTotals.cs @@ -0,0 +1,46 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +public struct JudgmentTotalSet { + public int Total {get; init;} + public int? Early {get; init;} + public int? Late {get; init;} +} + +public interface JudgmentTotals { + public JsonObject ToJson(); +} + +public class JudgmentTotals : JudgmentTotals, IEnumerable> where T: struct { + private Dictionary totals = []; + + public JsonObject ToJson() { + JsonObject json = new(); + foreach (KeyValuePair entry in totals) { + JsonObject totalsObj = new(); + totalsObj["total"] = entry.Value.Total; + if (entry.Value.Early != null) totalsObj["early"] = entry.Value.Early; + if (entry.Value.Late != null) totalsObj["late"] = entry.Value.Late; + json[entry.Key.ToString()] = totalsObj; + } + return json; + } + + public IEnumerator> GetEnumerator() { + return totals.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } + + public void Add(T key, JudgmentTotalSet value) { + totals.Add(key, value); + } + + public JudgmentTotalSet this[T judgment] { + get => totals[judgment]; + set => totals[judgment] = value; + } +} \ No newline at end of file diff --git a/game-example/score/JudgmentTotals.cs.uid b/game-example/score/JudgmentTotals.cs.uid new file mode 100644 index 0000000..19636b2 --- /dev/null +++ b/game-example/score/JudgmentTotals.cs.uid @@ -0,0 +1 @@ +uid://c5gxo6dxwh1ww diff --git a/game-example/score/TsunagiteScore.cs b/game-example/score/TsunagiteScore.cs new file mode 100644 index 0000000..0fc1695 --- /dev/null +++ b/game-example/score/TsunagiteScore.cs @@ -0,0 +1,45 @@ +using System; +using System.Text.Json.Nodes; +using PeterO.Cbor; +using System.Collections.Generic; + +//A score object formatted for Tsunagite. +public class TsunagiteScore { + //The date+time this score was achieved at. + public DateTime Timestamp {get; set;} + //The record key of the song this score was on. Will be auto-assembled into a full URI if not one already. + public string SongKey {get; set;} + //The record key of the chart this score was on. Will be auto-assembled into a full URI if not one already. + public string ChartKey {get; set;} + public JudgmentTotals Judgments {get; set;} + public Dictionary ScoreComponents {get; set;} + + public JsonObject ToJson() { + JsonObject json = new(); + json["$type"] = "dev.tsunagite.score"; + json["game"] = $"at://{TsunagiteHandler.OwnerHandle}/dev.tsunagite.game/{TsunagiteHandler.GameId}"; + if (SongKey.StartsWith("at://")) { + json["song"] = SongKey; + } else { + json["song"] = $"at://{TsunagiteHandler.OwnerHandle}/dev.tsunagite.song/{SongKey}"; + } + if (ChartKey.StartsWith("at://")) { + json["chart"] = ChartKey; + } else { + json["chart"] = $"at://{TsunagiteHandler.OwnerHandle}/dev.tsunagite.chart/{ChartKey}"; + } + json["judgments"] = Judgments.ToJson(); + JsonObject scoreComps = new(); + foreach (KeyValuePair element in ScoreComponents) { + JsonObject comp = new(); + comp["$type"] = element.Value.GetComponentType(); + comp["value"] = element.Value.GetValue(); + scoreComps[element.Key] = comp; + } + return json; + } + + public CBORObject ToCbor() { + return CBORObject.FromJSONString(ToJson().ToJsonString()); + } +} \ No newline at end of file diff --git a/game-example/score/TsunagiteScore.cs.uid b/game-example/score/TsunagiteScore.cs.uid new file mode 100644 index 0000000..034ab9c --- /dev/null +++ b/game-example/score/TsunagiteScore.cs.uid @@ -0,0 +1 @@ +uid://3tusevbk11g diff --git a/game-example/score/component/EnumComponent.cs b/game-example/score/component/EnumComponent.cs new file mode 100644 index 0000000..cb03472 --- /dev/null +++ b/game-example/score/component/EnumComponent.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +public class EnumComponent(T value) : ScoreComponent where T: struct { + public string GetComponentType() { + return "dev.tsunagite.score#enum"; + } + public JsonNode GetValue() { + return value.ToString(); + } +} \ No newline at end of file diff --git a/game-example/score/component/EnumComponent.cs.uid b/game-example/score/component/EnumComponent.cs.uid new file mode 100644 index 0000000..23c2514 --- /dev/null +++ b/game-example/score/component/EnumComponent.cs.uid @@ -0,0 +1 @@ +uid://b2hllubibn6r2 diff --git a/game-example/score/component/PercentageComponent.cs b/game-example/score/component/PercentageComponent.cs new file mode 100644 index 0000000..0507c4a --- /dev/null +++ b/game-example/score/component/PercentageComponent.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +public class PercentageComponent(float value, int precision) : ScoreComponent { + public string GetComponentType() { + return "dev.tsunagite.score#percentage"; + } + public JsonNode GetValue() { + return value.ToString($"0.{new string('0', precision)}"); + } +} \ No newline at end of file diff --git a/game-example/score/component/PercentageComponent.cs.uid b/game-example/score/component/PercentageComponent.cs.uid new file mode 100644 index 0000000..504454b --- /dev/null +++ b/game-example/score/component/PercentageComponent.cs.uid @@ -0,0 +1 @@ +uid://dw7kpanmfregs diff --git a/game-example/score/component/PointsComponent b/game-example/score/component/PointsComponent new file mode 100644 index 0000000..05b5a67 --- /dev/null +++ b/game-example/score/component/PointsComponent @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +public class PointsComponent(int value) : ScoreComponent { + public string GetComponentType() { + return "dev.tsunagite.score#points"; + } + public JsonNode GetValue() { + return value; + } +} \ No newline at end of file diff --git a/game-example/score/component/PointsComponent.cs b/game-example/score/component/PointsComponent.cs new file mode 100644 index 0000000..05b5a67 --- /dev/null +++ b/game-example/score/component/PointsComponent.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +public class PointsComponent(int value) : ScoreComponent { + public string GetComponentType() { + return "dev.tsunagite.score#points"; + } + public JsonNode GetValue() { + return value; + } +} \ No newline at end of file diff --git a/game-example/score/component/PointsComponent.cs.uid b/game-example/score/component/PointsComponent.cs.uid new file mode 100644 index 0000000..c4521b2 --- /dev/null +++ b/game-example/score/component/PointsComponent.cs.uid @@ -0,0 +1 @@ +uid://b8l6gaffqftwo diff --git a/game-example/score/component/ScoreComponent.cs b/game-example/score/component/ScoreComponent.cs new file mode 100644 index 0000000..d3e9cdd --- /dev/null +++ b/game-example/score/component/ScoreComponent.cs @@ -0,0 +1,7 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +public interface ScoreComponent { + string GetComponentType(); + JsonNode GetValue(); +} \ No newline at end of file diff --git a/game-example/score/component/ScoreComponent.cs.uid b/game-example/score/component/ScoreComponent.cs.uid new file mode 100644 index 0000000..60f2187 --- /dev/null +++ b/game-example/score/component/ScoreComponent.cs.uid @@ -0,0 +1 @@ +uid://78vrgasnco5s diff --git a/game-example/score/component/TextComponent.cs b/game-example/score/component/TextComponent.cs new file mode 100644 index 0000000..1c0ea70 --- /dev/null +++ b/game-example/score/component/TextComponent.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Nodes; + +public class TextComponent(string value) : ScoreComponent { + public string GetComponentType() { + return "dev.tsunagite.score#text"; + } + public JsonNode GetValue() { + return value; + } +} \ No newline at end of file diff --git a/game-example/score/component/TextComponent.cs.uid b/game-example/score/component/TextComponent.cs.uid new file mode 100644 index 0000000..2d4a99d --- /dev/null +++ b/game-example/score/component/TextComponent.cs.uid @@ -0,0 +1 @@ +uid://or5vnf5ar3my diff --git a/game-example/Handles.cs b/game-example/util/Handles.cs similarity index 82% rename from game-example/Handles.cs rename to game-example/util/Handles.cs index 1864e6d..b103e5d 100644 --- a/game-example/Handles.cs +++ b/game-example/util/Handles.cs @@ -10,8 +10,22 @@ using System.Threading.Tasks; //A fully do-it-yourself handle->did+pds url resolver, not depending on an online pds to resolve for you. //This kinda sucks but it beats having to depend on a specific pds staying alive forever public struct HandleResolution { + public readonly string Handle {get; init;} public readonly string Did {get; init;} public readonly string PdsAddress {get; init;} + + public static HandleResolution FromString(string input) { + JsonElement json = JsonDocument.Parse(input).RootElement; + return new HandleResolution() { + Handle = json.GetProperty("handle").GetString(), + Did = json.GetProperty("did").GetString(), + PdsAddress = json.GetProperty("pds").GetString() + }; + } + + public override readonly string ToString() { + return $"{{\"handle\":\"{Handle}\", \"did\": \"{Did}\", \"pds\": \"{PdsAddress}\"}}"; + } } public class Handles { private static System.Net.Http.HttpClient client = new(); @@ -59,8 +73,8 @@ public class Handles { string addr = did.Substring("did:web:".Length); resolveAddress = new($"{addr}/.well-known/did.json"); } else /*if (did.StartsWith("did:plc:"))*/ { - //NOTE: plc.directory is hardcoded for this example, but mirrors may crop up soon! - resolveAddress = new($"https://plc.directory/{did}"); + //NOTE: plc.wtf is hardcoded for this example, but plc.directory is upstream and there are other mirrors! + resolveAddress = new($"https://plc.wtf/{did}"); } HttpResponseMessage httpRes = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, resolveAddress)); @@ -70,6 +84,7 @@ public class Handles { foreach (JsonElement elem in services) { if (elem.GetProperty("id").GetString() == "#atproto_pds") { return new HandleResolution() { + Handle = handle, Did = did, PdsAddress = elem.GetProperty("serviceEndpoint").GetString() }; diff --git a/game-example/Handles.cs.uid b/game-example/util/Handles.cs.uid similarity index 100% rename from game-example/Handles.cs.uid rename to game-example/util/Handles.cs.uid diff --git a/game-example/util/TimestampId.cs b/game-example/util/TimestampId.cs new file mode 100644 index 0000000..ebc5d98 --- /dev/null +++ b/game-example/util/TimestampId.cs @@ -0,0 +1,30 @@ +using System; + +public class TimestampId { + //Base32 encode with encoding variant sort - ported from Bluesky official code + private const string S32_CHAR = "234567abcdefghijklmnopqrstuvwxyz"; + + public static string S32Encode(long value) { + string s = ""; + while (value > 0) { + int c = (int) (value % 32); + value = value/32; + s = S32_CHAR[c] + s; + } + return s; + } + + public static long S32Decode(string value) { + long i = 0; + foreach (char c in value) { + i = i * 32 + S32_CHAR.IndexOf(c); + } + return i; + } + + public static string EncodeTid(DateTime timestamp) { + //Bsky uses a clock ID since JS time doesn't have microseconds, + //but DateTime ticks are 100 nanoseonds so we can just div by 10 to get microseconds! + return S32Encode(timestamp.Ticks/10); + } +} \ No newline at end of file diff --git a/game-example/util/TimestampId.cs.uid b/game-example/util/TimestampId.cs.uid new file mode 100644 index 0000000..c38c593 --- /dev/null +++ b/game-example/util/TimestampId.cs.uid @@ -0,0 +1 @@ +uid://c6w78qapy7307