World microapi
Some checks failed
Deploy Promiscuity Auth API / deploy (push) Successful in 49s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Crafting API / deploy (push) Successful in 47s
Deploy Promiscuity Inventory API / deploy (push) Successful in 47s
Deploy Promiscuity Locations API / deploy (push) Successful in 48s
Deploy Promiscuity Mail API / deploy (push) Successful in 46s
k8s smoke test / test (push) Has been cancelled

This commit is contained in:
Zeeshaun 2026-04-01 19:53:18 +00:00
parent 94473ab1b0
commit 2bed6aae4f
12 changed files with 328 additions and 2 deletions

View File

@ -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 LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations"
const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory" const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory"
const MAIL_API_URL := "https://pmail.ranaze.com/api/mail" 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 START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn" const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn"
const CHARACTER_SLOT_COUNT := 6 const CHARACTER_SLOT_COUNT := 6
const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0 const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0
const HEARTBEAT_INTERVAL := 10.0 const HEARTBEAT_INTERVAL := 10.0
const WORLD_CYCLE_REFRESH_INTERVAL := 15.0
@export var tile_size := 8.0 @export var tile_size := 8.0
@export var block_height := 1.0 @export var block_height := 1.0
@ -77,6 +79,9 @@ var _mail_sent: Array = []
var _mail_request_in_flight := false var _mail_request_in_flight := false
var _selected_inbox_mail_id := "" var _selected_inbox_mail_id := ""
var _selected_sent_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: func _ready() -> void:
@ -102,6 +107,7 @@ func _ready() -> void:
_block.visible = false _block.visible = false
_deactivate_player_for_load() _deactivate_player_for_load()
await _load_existing_locations() await _load_existing_locations()
_refresh_world_cycle()
_refresh_visible_characters() _refresh_visible_characters()
_ensure_selected_location_exists(_center_coord) _ensure_selected_location_exists(_center_coord)
_rebuild_tiles(_center_coord) _rebuild_tiles(_center_coord)
@ -114,12 +120,16 @@ func _process(_delta: float) -> void:
return return
_visible_character_refresh_elapsed += _delta _visible_character_refresh_elapsed += _delta
_heartbeat_elapsed += _delta _heartbeat_elapsed += _delta
_world_cycle_refresh_elapsed += _delta
if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL: if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL:
_visible_character_refresh_elapsed = 0.0 _visible_character_refresh_elapsed = 0.0
_refresh_visible_characters() _refresh_visible_characters()
if _heartbeat_elapsed >= HEARTBEAT_INTERVAL: if _heartbeat_elapsed >= HEARTBEAT_INTERVAL:
_heartbeat_elapsed = 0.0 _heartbeat_elapsed = 0.0
_send_presence_heartbeat() _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: if _inventory_menu.visible or _mail_menu.visible:
return return
var target_world_pos := _get_stream_position() var target_world_pos := _get_stream_position()
@ -1321,8 +1331,52 @@ func _refresh_visible_locations() -> void:
_refresh_visible_locations_async() _refresh_visible_locations_async()
func _refresh_visible_locations_async() -> void: func _refresh_visible_locations_async() -> void:
await _load_existing_locations() 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: func _try_interact_current_tile() -> void:

View File

@ -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());
}
}

View File

@ -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
}
```

View File

@ -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; }
}

View File

@ -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<WorldCycleService>();
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<string>()
}
});
});
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<IExceptionHandlerFeature>();
var exception = feature?.Error;
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().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();

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,3 @@
# WorldApi
Global world simulation state such as the shared day/night cycle.

View File

@ -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<double?>("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"
};
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -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": "*"
}

View File

@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MailApi", "MailApi\MailApi.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\CraftingApi.csproj", "{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\CraftingApi.csproj", "{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE