yuanshen/Yuanshen.SDK/Handlers/GachaHistoryHandler.cs
wisedev fc2ac1050f First working version of the server
KCP gameserver with packet dispatch, scene streaming, combat, gacha and the
shop, plus a small HTTP server for the login flow and the wish history page.

Game data under assets is pulled out of the client instead of being typed by
hand. The excel tables come from the packed bin container and the spawn points
from the compiled scene scripts.
2026-09-04 02:10:50 +03:00

252 lines
11 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Npgsql;
namespace Yuanshen.SDK.Handlers;
public sealed class GachaLogRow
{
[JsonPropertyName("gachaType")] public uint GachaType { get; set; }
[JsonPropertyName("itemId")] public uint ItemId { get; set; }
[JsonPropertyName("rankLevel")] public uint RankLevel { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("kind")] public string Kind { get; set; } = string.Empty;
[JsonPropertyName("time")] public long Time { get; set; }
}
public sealed class PlayerSnapshot
{
[JsonPropertyName("gachaLog")] public List<GachaLogRow> GachaLog { get; set; } = new();
[JsonPropertyName("gachaPity")] public Dictionary<string, uint> GachaPity { get; set; } = new();
[JsonPropertyName("nickname")] public string Nickname { get; set; } = string.Empty;
}
public static class GachaHistoryHandler
{
private const string SelectSql = "SELECT data FROM players ORDER BY updated_at DESC LIMIT 1";
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private static readonly Dictionary<uint, string> BannerNames = new()
{
[100] = "Novice",
[200] = "Standard",
[301] = "Character Event",
[302] = "Weapon Event"
};
private static readonly string[] WeaponTypes =
{ "Weapon", "Sword", "Claymore", "Polearm", "Catalyst", "Bow" };
private static string? _connectionString;
public static void Use(string connectionString) => _connectionString = connectionString;
public static async Task<IResult> OnHistory(HttpRequest request)
{
PlayerSnapshot snapshot;
try
{
snapshot = await LoadAsync();
}
catch (Exception exception)
{
Log.Warn($"gacha history unavailable: {exception.Message}");
return Html(Page("Wish History", $"<p class=\"empty\">Could not read the database.<br><span class=\"dim\">{WebUtility.HtmlEncode(exception.Message)}</span></p>"));
}
List<GachaLogRow> log = snapshot.GachaLog;
if (log.Count == 0)
return Html(Page("Wish History", "<p class=\"empty\">No wishes yet.<br><span class=\"dim\">Pull on a banner and it will show up here.</span></p>"));
uint filter = request.Query.TryGetValue("banner", out Microsoft.Extensions.Primitives.StringValues raw)
&& uint.TryParse(raw, out uint parsed)
? parsed
: 0;
List<GachaLogRow> shown = filter == 0 ? log : log.Where(entry => entry.GachaType == filter).ToList();
shown = Enumerable.Reverse(shown).ToList();
StringBuilder body = new();
body.Append(Stats(shown, snapshot.GachaPity, filter));
body.Append(Tabs(log, filter));
if (shown.Count == 0)
{
body.Append("<p class=\"empty\">Nothing on this banner yet.</p>");
return Html(Page("Wish History", body.ToString()));
}
body.Append("<div class=\"list\">");
string lastDate = string.Empty;
int index = shown.Count;
foreach (GachaLogRow entry in shown)
{
DateTime when = DateTimeOffset.FromUnixTimeSeconds(entry.Time).LocalDateTime;
string date = when.ToString("dd MMM yyyy");
if (date != lastDate)
{
body.Append($"<div class=\"day\">{date}</div>");
lastDate = date;
}
uint rank = Math.Clamp(entry.RankLevel, 3, 5);
string label = WebUtility.HtmlEncode(Label(entry));
string banner = WebUtility.HtmlEncode(BannerNames.GetValueOrDefault(entry.GachaType, $"Type {entry.GachaType}"));
body.Append(
$"<div class=\"row r{rank}\">"
+ Icon(entry)
+ $"<span class=\"pip\">{rank}★</span>"
+ $"<span class=\"name\">{label}</span>"
+ $"<span class=\"id\">{entry.ItemId}</span>"
+ $"<span class=\"banner\">{banner}</span>"
+ $"<span class=\"time\">{when:HH:mm}</span>"
+ $"<span class=\"num\">#{index--}</span>"
+ "</div>");
}
body.Append("</div>");
return Html(Page("Wish History", body.ToString()));
}
private static uint WeaponSlot(GachaLogRow entry)
=> entry.ItemId >= 10000 ? entry.ItemId / 1000 % 10 : 0;
private static string Icon(GachaLogRow entry)
{
string glyph = WeaponSlot(entry) switch
{
1 => "<path d='M6 26 L20 12 M18 10 l4 4 M22 6 l4 4 -4 4 -4 -4z' />",
2 => "<path d='M7 25 l5-5 M11 19 l6-10 4 4 -10 6z M20 8 l4 4' />",
3 => "<path d='M6 26 L24 8 M20 4 l8 0 0 8' />",
4 => "<circle cx='16' cy='16' r='6' /><path d='M16 4 v4 M16 24 v4 M4 16 h4 M24 16 h4' />",
5 => "<path d='M9 6 a14 14 0 0 1 0 20 M9 6 L26 16 9 26' />",
_ => "<circle cx='16' cy='12' r='5' /><path d='M7 27 a9 9 0 0 1 18 0' />"
};
return $"<span class=\"ico\"><svg viewBox='0 0 32 32' fill='none' stroke='currentColor' "
+ $"stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>{glyph}</svg></span>";
}
private static string Label(GachaLogRow entry)
{
if (!string.IsNullOrEmpty(entry.Name))
return entry.Name;
if (entry.ItemId >= 10000)
{
uint type = entry.ItemId / 1000 % 10;
return type < WeaponTypes.Length ? WeaponTypes[type] : "Weapon";
}
return $"Character {entry.ItemId}";
}
private static string Stats(List<GachaLogRow> shown, Dictionary<string, uint> pity, uint filter)
{
int five = shown.Count(entry => entry.RankLevel >= 5);
int four = shown.Count(entry => entry.RankLevel == 4);
string pityChip = string.Empty;
if (filter != 0 && pity.TryGetValue(filter.ToString(), out uint counter))
pityChip = Chip("Pity", counter.ToString(), "c-pity");
return "<div class=\"stats\">"
+ Chip("Wishes", shown.Count.ToString(), "c-all")
+ Chip("5★", five.ToString(), "c5")
+ Chip("4★", four.ToString(), "c4")
+ pityChip
+ "</div>";
}
private static string Chip(string label, string value, string cls)
=> $"<div class=\"chip {cls}\"><b>{value}</b><span>{WebUtility.HtmlEncode(label)}</span></div>";
private static string Tabs(List<GachaLogRow> log, uint filter)
{
StringBuilder tabs = new("<div class=\"tabs\">");
tabs.Append(Tab("All", 0, filter));
foreach (uint type in log.Select(entry => entry.GachaType).Distinct().OrderBy(type => type))
tabs.Append(Tab(BannerNames.GetValueOrDefault(type, $"Type {type}"), type, filter));
tabs.Append("</div>");
return tabs.ToString();
}
private static string Tab(string label, uint type, uint active)
=> $"<a class=\"tab{(type == active ? " on" : string.Empty)}\" href=\"?banner={type}\">{WebUtility.HtmlEncode(label)}</a>";
private static async Task<PlayerSnapshot> LoadAsync()
{
if (string.IsNullOrEmpty(_connectionString))
throw new InvalidOperationException("database connection string is not configured");
await using NpgsqlConnection connection = new(_connectionString);
await connection.OpenAsync();
await using NpgsqlCommand command = new(SelectSql, connection);
object? result = await command.ExecuteScalarAsync();
if (result is not string json)
return new PlayerSnapshot();
return JsonSerializer.Deserialize<PlayerSnapshot>(json, Options) ?? new PlayerSnapshot();
}
private static IResult Html(string html) => TypedResults.Text(html, "text/html; charset=utf-8");
private static string Page(string title, string body) => $$"""
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{{title}}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box}
body{background:#16101c;color:#ece4f5;font:17px/1.55 -apple-system,"Segoe UI",Roboto,sans-serif;margin:0;padding:34px 20px 64px}
.wrap{max-width:1040px;margin:0 auto}
h1{font-size:30px;margin:0 0 22px;background:linear-gradient(90deg,#a35cff,#e040fb);-webkit-background-clip:text;background-clip:text;color:transparent;display:inline-block}
.stats{display:flex;gap:14px;margin-bottom:24px;flex-wrap:wrap}
.chip{background:#221a2c;border:1px solid #322640;border-radius:14px;padding:13px 22px;min-width:112px}
.chip b{display:block;font-size:30px;line-height:1.1}
.chip span{font-size:13px;color:#8b7d9b;letter-spacing:.05em;text-transform:uppercase}
.c5 b{color:#ffb34d}.c4 b{color:#c77dff}.c-pity b{color:#7ce8a4}.c-all b{color:#ece4f5}
.tabs{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:22px}
.tab{padding:10px 20px;border-radius:999px;background:#221a2c;color:#a89bb8;text-decoration:none;font-size:16px;border:1px solid transparent}
.tab.on{background:linear-gradient(90deg,#7b2ff7,#e040fb);color:#fff}
.day{font-size:13px;color:#6b5e7a;letter-spacing:.1em;text-transform:uppercase;margin:26px 0 10px;padding-left:4px}
.list{display:flex;flex-direction:column;gap:6px}
.row{display:grid;grid-template-columns:52px 46px 1fr auto auto 64px 62px;align-items:center;gap:16px;
padding:14px 18px;border-radius:13px;background:#1c1524;border-left:4px solid #2e2438}
.ico{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;
background:#2a2136;color:#8b7d9b;border:1px solid #35283f}
.ico svg{width:26px;height:26px}
.pip{font-size:16px;font-weight:600;color:#7d6f8d}
.name{font-weight:600;font-size:19px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.id{font-size:15px;color:#5f5470;font-variant-numeric:tabular-nums}
.banner{font-size:16px;color:#8b7d9b;white-space:nowrap}
.time{font-size:16px;color:#6b5e7a;font-variant-numeric:tabular-nums;text-align:right}
.num{font-size:14px;color:#463c53;text-align:right;font-variant-numeric:tabular-nums}
.r3{opacity:.78}
.r4{border-left-color:#c77dff;background:#241b31}
.r4 .pip,.r4 .name{color:#d9b0ff}
.r4 .ico{background:#33234a;color:#d9b0ff;border-color:#4a3363}
.r5{border-left-color:#ffb34d;background:#2c2219;box-shadow:0 0 0 1px #4a3a1e inset}
.r5 .pip,.r5 .name{color:#ffc978}
.r5 .ico{background:#463217;color:#ffc978;border-color:#6b4d21}
.empty{color:#8b7d9b;background:#1c1524;border-radius:14px;padding:34px;text-align:center;font-size:18px}
.dim{color:#6b5e7a;font-size:15px}
</style></head>
<body><div class="wrap"><h1>{{title}}</h1>{{body}}</div></body></html>
""";
}