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.
131 lines
3.9 KiB
C#
131 lines
3.9 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Npgsql;
|
|
using NpgsqlTypes;
|
|
using Yuanshen.Gameserver.Options;
|
|
|
|
namespace Yuanshen.Gameserver.Database;
|
|
|
|
internal sealed class PostgresPlayerRepository : IPlayerRepository, IAsyncDisposable
|
|
{
|
|
private const string CreateTableSql =
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS players (
|
|
uid bigint PRIMARY KEY,
|
|
data jsonb NOT NULL,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
)
|
|
""";
|
|
|
|
private const string UpsertSql =
|
|
"""
|
|
INSERT INTO players (uid, data, updated_at)
|
|
VALUES (@uid, @data, now())
|
|
ON CONFLICT (uid) DO UPDATE
|
|
SET data = EXCLUDED.data, updated_at = now()
|
|
""";
|
|
|
|
private const string SelectSql = "SELECT data FROM players WHERE uid = @uid";
|
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly ILogger _logger;
|
|
private readonly SemaphoreSlim _schemaLock = new(1, 1);
|
|
private volatile bool _schemaReady;
|
|
|
|
public PostgresPlayerRepository(IOptions<DatabaseOptions> options, ILogger<PostgresPlayerRepository> logger)
|
|
{
|
|
_logger = logger;
|
|
_dataSource = new NpgsqlDataSourceBuilder(options.Value.ConnectionString).Build();
|
|
}
|
|
|
|
private async ValueTask<bool> EnsureSchemaAsync()
|
|
{
|
|
if (_schemaReady)
|
|
return true;
|
|
|
|
await _schemaLock.WaitAsync();
|
|
try
|
|
{
|
|
if (_schemaReady)
|
|
return true;
|
|
|
|
await using NpgsqlCommand command = _dataSource.CreateCommand(CreateTableSql);
|
|
await command.ExecuteNonQueryAsync();
|
|
|
|
_schemaReady = true;
|
|
_logger.LogInformation("PostgreSQL connected, player storage ready");
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogWarning("PostgreSQL unavailable, persistence disabled for now: {message}", exception.Message);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_schemaLock.Release();
|
|
}
|
|
}
|
|
|
|
public async ValueTask<PlayerRecord?> LoadAsync(uint uid)
|
|
{
|
|
if (!await EnsureSchemaAsync())
|
|
return null;
|
|
|
|
try
|
|
{
|
|
await using NpgsqlCommand command = _dataSource.CreateCommand(SelectSql);
|
|
command.Parameters.AddWithValue("uid", (long)uid);
|
|
|
|
object? result = await command.ExecuteScalarAsync();
|
|
if (result is not string json)
|
|
return null;
|
|
|
|
return JsonSerializer.Deserialize<PlayerRecord>(json, SerializerOptions);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError("Failed to load player {uid}: {message}", uid, exception.Message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async ValueTask<bool> SaveAsync(PlayerRecord record)
|
|
{
|
|
if (!await EnsureSchemaAsync())
|
|
return false;
|
|
|
|
record.LastSeen = DateTimeOffset.UtcNow;
|
|
|
|
try
|
|
{
|
|
await using NpgsqlCommand command = _dataSource.CreateCommand(UpsertSql);
|
|
command.Parameters.AddWithValue("uid", (long)record.Uid);
|
|
command.Parameters.Add(new NpgsqlParameter("data", NpgsqlDbType.Jsonb)
|
|
{
|
|
Value = JsonSerializer.Serialize(record, SerializerOptions)
|
|
});
|
|
|
|
await command.ExecuteNonQueryAsync();
|
|
_logger.LogDebug("Saved player {uid}", record.Uid);
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError("Failed to save player {uid}: {message}", record.Uid, exception.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
_schemaLock.Dispose();
|
|
return _dataSource.DisposeAsync();
|
|
}
|
|
}
|