From 2bed6aae4f23e756273f4106d0bf0b0c1f886225 Mon Sep 17 00:00:00 2001 From: Zeeshaun Date: Wed, 1 Apr 2026 19:53:18 +0000 Subject: [PATCH] World microapi --- game/scenes/Levels/location_level.gd | 58 +++++++++- .../WorldApi/Controllers/WorldController.cs | 24 ++++ microservices/WorldApi/DOCUMENTS.md | 15 +++ .../WorldApi/Models/WorldCycleResponse.cs | 18 +++ microservices/WorldApi/Program.cs | 106 ++++++++++++++++++ .../WorldApi/Properties/launchSettings.json | 14 +++ microservices/WorldApi/README.md | 3 + .../WorldApi/Services/WorldCycleService.cs | 56 +++++++++ microservices/WorldApi/WorldApi.csproj | 15 +++ .../WorldApi/appsettings.Development.json | 8 ++ microservices/WorldApi/appsettings.json | 7 ++ microservices/micro-services.sln | 6 + 12 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 microservices/WorldApi/Controllers/WorldController.cs create mode 100644 microservices/WorldApi/DOCUMENTS.md create mode 100644 microservices/WorldApi/Models/WorldCycleResponse.cs create mode 100644 microservices/WorldApi/Program.cs create mode 100644 microservices/WorldApi/Properties/launchSettings.json create mode 100644 microservices/WorldApi/README.md create mode 100644 microservices/WorldApi/Services/WorldCycleService.cs create mode 100644 microservices/WorldApi/WorldApi.csproj create mode 100644 microservices/WorldApi/appsettings.Development.json create mode 100644 microservices/WorldApi/appsettings.json diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index be144f0..1de2177 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -4,11 +4,13 @@ const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory" const MAIL_API_URL := "https://pmail.ranaze.com/api/mail" +const WORLD_API_URL := "https://pworld.ranaze.com/api/world" const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn" const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn" const CHARACTER_SLOT_COUNT := 6 const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0 const HEARTBEAT_INTERVAL := 10.0 +const WORLD_CYCLE_REFRESH_INTERVAL := 15.0 @export var tile_size := 8.0 @export var block_height := 1.0 @@ -77,6 +79,9 @@ var _mail_sent: Array = [] var _mail_request_in_flight := false var _selected_inbox_mail_id := "" var _selected_sent_mail_id := "" +var _world_cycle_request_in_flight := false +var _world_cycle_refresh_elapsed := 0.0 +var _world_cycle: Dictionary = {} func _ready() -> void: @@ -102,6 +107,7 @@ func _ready() -> void: _block.visible = false _deactivate_player_for_load() await _load_existing_locations() + _refresh_world_cycle() _refresh_visible_characters() _ensure_selected_location_exists(_center_coord) _rebuild_tiles(_center_coord) @@ -114,12 +120,16 @@ func _process(_delta: float) -> void: return _visible_character_refresh_elapsed += _delta _heartbeat_elapsed += _delta + _world_cycle_refresh_elapsed += _delta if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL: _visible_character_refresh_elapsed = 0.0 _refresh_visible_characters() if _heartbeat_elapsed >= HEARTBEAT_INTERVAL: _heartbeat_elapsed = 0.0 _send_presence_heartbeat() + if _world_cycle_refresh_elapsed >= WORLD_CYCLE_REFRESH_INTERVAL: + _world_cycle_refresh_elapsed = 0.0 + _refresh_world_cycle() if _inventory_menu.visible or _mail_menu.visible: return var target_world_pos := _get_stream_position() @@ -1321,8 +1331,52 @@ func _refresh_visible_locations() -> void: _refresh_visible_locations_async() -func _refresh_visible_locations_async() -> void: - await _load_existing_locations() +func _refresh_visible_locations_async() -> void: + await _load_existing_locations() + + +func _refresh_world_cycle() -> void: + if _world_cycle_request_in_flight: + return + _refresh_world_cycle_async() + + +func _refresh_world_cycle_async() -> void: + _world_cycle_request_in_flight = true + + var request := HTTPRequest.new() + add_child(request) + + var headers := PackedStringArray() + if not AuthState.access_token.is_empty(): + headers.append("Authorization: Bearer %s" % AuthState.access_token) + + var err := request.request("%s/cycle" % WORLD_API_URL, headers, HTTPClient.METHOD_GET) + if err != OK: + push_warning("Failed to request world cycle: %s" % err) + request.queue_free() + _world_cycle_request_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + + var result_code: int = result[0] + var response_code: int = result[1] + var response_body: String = result[3].get_string_from_utf8() + if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Failed to load world cycle (%s/%s): %s" % [result_code, response_code, response_body]) + _world_cycle_request_in_flight = false + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("World cycle response was not an object.") + _world_cycle_request_in_flight = false + return + + _world_cycle = parsed as Dictionary + _world_cycle_request_in_flight = false func _try_interact_current_tile() -> void: diff --git a/microservices/WorldApi/Controllers/WorldController.cs b/microservices/WorldApi/Controllers/WorldController.cs new file mode 100644 index 0000000..d9743e7 --- /dev/null +++ b/microservices/WorldApi/Controllers/WorldController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WorldApi.Services; + +namespace WorldApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class WorldController : ControllerBase +{ + private readonly WorldCycleService _worldCycle; + + public WorldController(WorldCycleService worldCycle) + { + _worldCycle = worldCycle; + } + + [HttpGet("cycle")] + [Authorize(Roles = "USER,SUPER")] + public IActionResult GetCycle() + { + return Ok(_worldCycle.GetCurrentCycle()); + } +} diff --git a/microservices/WorldApi/DOCUMENTS.md b/microservices/WorldApi/DOCUMENTS.md new file mode 100644 index 0000000..35659ce --- /dev/null +++ b/microservices/WorldApi/DOCUMENTS.md @@ -0,0 +1,15 @@ +# WorldApi document shapes + +Outbound JSON documents +- WorldCycleResponse (`GET /api/world/cycle`) + ```json + { + "currentUtc": "string (ISO-8601 datetime)", + "dayLengthSeconds": 1200, + "timeOfDaySeconds": 438.2, + "timeOfDayNormalized": 0.365, + "phase": "day", + "isDay": true, + "lightLevel": 0.91 + } + ``` diff --git a/microservices/WorldApi/Models/WorldCycleResponse.cs b/microservices/WorldApi/Models/WorldCycleResponse.cs new file mode 100644 index 0000000..9aa5402 --- /dev/null +++ b/microservices/WorldApi/Models/WorldCycleResponse.cs @@ -0,0 +1,18 @@ +namespace WorldApi.Models; + +public class WorldCycleResponse +{ + public DateTime CurrentUtc { get; set; } + + public double DayLengthSeconds { get; set; } + + public double TimeOfDaySeconds { get; set; } + + public double TimeOfDayNormalized { get; set; } + + public string Phase { get; set; } = "day"; + + public bool IsDay { get; set; } + + public double LightLevel { get; set; } +} diff --git a/microservices/WorldApi/Program.cs b/microservices/WorldApi/Program.cs new file mode 100644 index 0000000..57d8362 --- /dev/null +++ b/microservices/WorldApi/Program.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; +using WorldApi.Services; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +builder.Services.AddSingleton(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "World API", Version = "v1" }); + c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Paste your access token here (no 'Bearer ' prefix needed)." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() + } + }); +}); + +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "promiscuity"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var exception = feature?.Error; + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + var traceId = context.TraceIdentifier; + + if (exception is not null) + { + logger.LogError( + exception, + "Unhandled exception for {Method} {Path}. TraceId={TraceId}", + context.Request.Method, + context.Request.Path, + traceId + ); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/problem+json"; + + await context.Response.WriteAsJsonAsync(new + { + type = "https://httpstatuses.com/500", + title = "Internal Server Error", + status = 500, + detail = exception?.Message ?? "An unexpected server error occurred.", + traceId + }); + }); +}); + +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseSwagger(); +app.UseSwaggerUI(o => +{ + o.SwaggerEndpoint("/swagger/v1/swagger.json", "World API v1"); + o.RoutePrefix = "swagger"; +}); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/microservices/WorldApi/Properties/launchSettings.json b/microservices/WorldApi/Properties/launchSettings.json new file mode 100644 index 0000000..4604d89 --- /dev/null +++ b/microservices/WorldApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5185", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/microservices/WorldApi/README.md b/microservices/WorldApi/README.md new file mode 100644 index 0000000..1efecff --- /dev/null +++ b/microservices/WorldApi/README.md @@ -0,0 +1,3 @@ +# WorldApi + +Global world simulation state such as the shared day/night cycle. diff --git a/microservices/WorldApi/Services/WorldCycleService.cs b/microservices/WorldApi/Services/WorldCycleService.cs new file mode 100644 index 0000000..095ae42 --- /dev/null +++ b/microservices/WorldApi/Services/WorldCycleService.cs @@ -0,0 +1,56 @@ +using WorldApi.Models; + +namespace WorldApi.Services; + +public class WorldCycleService +{ + private readonly double _dayLengthSeconds; + private readonly DateTime _epochUtc; + + public WorldCycleService(IConfiguration configuration) + { + _dayLengthSeconds = Math.Max(1.0, configuration.GetValue("WorldCycle:DayLengthSeconds") ?? 1200.0); + + var configuredEpoch = configuration["WorldCycle:EpochUtc"]; + _epochUtc = DateTime.TryParse( + configuredEpoch, + null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var parsedEpoch) + ? parsedEpoch + : new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + public WorldCycleResponse GetCurrentCycle() + { + var now = DateTime.UtcNow; + var elapsedSeconds = Math.Max(0.0, (now - _epochUtc).TotalSeconds); + var timeOfDaySeconds = elapsedSeconds % _dayLengthSeconds; + var normalized = timeOfDaySeconds / _dayLengthSeconds; + var solarCurve = Math.Sin(normalized * Math.PI); + var lightLevel = Math.Clamp(solarCurve, 0.0, 1.0); + + return new WorldCycleResponse + { + CurrentUtc = now, + DayLengthSeconds = _dayLengthSeconds, + TimeOfDaySeconds = timeOfDaySeconds, + TimeOfDayNormalized = normalized, + Phase = ResolvePhase(normalized), + IsDay = lightLevel > 0.0, + LightLevel = lightLevel + }; + } + + private static string ResolvePhase(double normalized) + { + return normalized switch + { + < 0.20 => "night", + < 0.30 => "dawn", + < 0.70 => "day", + < 0.80 => "dusk", + _ => "night" + }; + } +} diff --git a/microservices/WorldApi/WorldApi.csproj b/microservices/WorldApi/WorldApi.csproj new file mode 100644 index 0000000..63f3fcf --- /dev/null +++ b/microservices/WorldApi/WorldApi.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/microservices/WorldApi/appsettings.Development.json b/microservices/WorldApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/microservices/WorldApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/microservices/WorldApi/appsettings.json b/microservices/WorldApi/appsettings.json new file mode 100644 index 0000000..6b98c49 --- /dev/null +++ b/microservices/WorldApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5004" } } }, + "WorldCycle": { "DayLengthSeconds": 1200, "EpochUtc": "2026-01-01T00:00:00Z" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln index fc911e1..adb2f3e 100644 --- a/microservices/micro-services.sln +++ b/microservices/micro-services.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\CraftingApi.csproj", "{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +46,10 @@ Global {0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.Build.0 = Debug|Any CPU {0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.ActiveCfg = Release|Any CPU {0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.Build.0 = Release|Any CPU + {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE