neon-sprawl/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateSto...

406 lines
13 KiB
C#

using System.Text.Json;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>PostgreSQL-backed quest progress keyed by normalized player id + quest id (NEO-116).</summary>
public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerQuestStateStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = QuestProgressIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
return PlayerExists(conn, player);
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return false;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("qid", quest);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return false;
}
snapshot = ReadSnapshot(reader, player, quest);
return true;
}
/// <inheritdoc />
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return false;
}
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_quest_progress (player_id, quest_id, status, current_step_index, objective_counters, updated_at)
VALUES (@pid, @qid, 'active', 0, '{}'::jsonb, now())
ON CONFLICT (player_id, quest_id) DO NOTHING;
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", player);
insert.Parameters.AddWithValue("qid", quest);
if (insert.ExecuteNonQuery() > 0)
{
tx.Commit();
snapshot = new QuestStepState(
player,
quest,
QuestProgressStatus.Active,
0,
new Dictionary<string, int>(StringComparer.Ordinal),
null);
return true;
}
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("qid", quest);
using var reader = sel.ExecuteReader();
if (!reader.Read())
{
tx.Rollback();
return false;
}
snapshot = ReadSnapshot(reader, player, quest);
}
tx.Rollback();
return false;
}
/// <inheritdoc />
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
{
snapshot = null!;
if (newStepIndex < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status != QuestProgressStatus.Active || newStepIndex <= row.CurrentStepIndex)
{
result = row;
return false;
}
result = new QuestStepState(
row.PlayerId,
row.QuestId,
row.Status,
newStepIndex,
new Dictionary<string, int>(StringComparer.Ordinal),
row.CompletedAt);
return true;
}, out snapshot);
}
/// <inheritdoc />
public bool TryUpdateObjectiveCounter(
string playerId,
string questId,
string objectiveId,
int newCount,
out QuestStepState snapshot)
{
snapshot = null!;
if (newCount < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
if (player.Length == 0 || quest.Length == 0 || objective.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status != QuestProgressStatus.Active)
{
result = row;
return false;
}
var counters = new Dictionary<string, int>(row.ObjectiveCounters, StringComparer.Ordinal);
counters[objective] = newCount;
result = new QuestStepState(
row.PlayerId,
row.QuestId,
row.Status,
row.CurrentStepIndex,
counters,
row.CompletedAt);
return true;
}, out snapshot);
}
/// <inheritdoc />
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0)
{
return false;
}
return TryMutateRow(player, quest, (QuestStepState row, out QuestStepState result) =>
{
if (row.Status == QuestProgressStatus.Completed)
{
result = row;
return false;
}
result = new QuestStepState(
row.PlayerId,
row.QuestId,
QuestProgressStatus.Completed,
row.CurrentStepIndex,
row.ObjectiveCounters,
completedAt);
return true;
}, out snapshot);
}
/// <inheritdoc />
public bool TryClearProgress(string playerId, string questId)
{
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
if (player.Length == 0 || quest.Length == 0 || !CanWritePlayer(player))
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("qid", quest);
cmd.ExecuteNonQuery();
return true;
}
private bool TryMutateRow(
string player,
string quest,
TryMutateRowDelegate mutator,
out QuestStepState snapshot)
{
snapshot = null!;
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, player, tx))
{
tx.Rollback();
return false;
}
QuestStepState current;
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
FROM player_quest_progress
WHERE player_id = @pid AND quest_id = @qid
LIMIT 1
FOR UPDATE;
""",
conn,
tx))
{
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("qid", quest);
using var reader = sel.ExecuteReader();
if (!reader.Read())
{
tx.Rollback();
return false;
}
current = ReadSnapshot(reader, player, quest);
}
if (!mutator(current, out var updated))
{
snapshot = current;
tx.Rollback();
return false;
}
WriteRow(conn, player, quest, updated, tx);
tx.Commit();
snapshot = updated;
return true;
}
private static void WriteRow(
Npgsql.NpgsqlConnection conn,
string player,
string quest,
QuestStepState row,
Npgsql.NpgsqlTransaction tx)
{
var countersJson = JsonSerializer.Serialize(row.ObjectiveCounters, JsonOptions);
using var cmd = new Npgsql.NpgsqlCommand(
"""
UPDATE player_quest_progress
SET status = @status,
current_step_index = @step,
objective_counters = @counters::jsonb,
completed_at = @completed_at,
updated_at = now()
WHERE player_id = @pid AND quest_id = @qid;
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("qid", quest);
cmd.Parameters.AddWithValue("status", ToPersistenceStatus(row.Status));
cmd.Parameters.AddWithValue("step", row.CurrentStepIndex);
cmd.Parameters.AddWithValue("counters", countersJson);
cmd.Parameters.AddWithValue("completed_at", (object?)row.CompletedAt ?? DBNull.Value);
cmd.ExecuteNonQuery();
}
private static QuestStepState ReadSnapshot(Npgsql.NpgsqlDataReader reader, string player, string quest)
{
var status = ParseStatus(reader.GetString(0));
var stepIndex = reader.GetInt32(1);
var countersJson = reader.GetFieldValue<string>(2);
var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3);
var counters = DeserializeCounters(countersJson);
return new QuestStepState(player, quest, status, stepIndex, counters, completedAt);
}
private static Dictionary<string, int> DeserializeCounters(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "{}")
{
return new Dictionary<string, int>(StringComparer.Ordinal);
}
var parsed = JsonSerializer.Deserialize<Dictionary<string, int>>(json, JsonOptions);
return parsed is null
? new Dictionary<string, int>(StringComparer.Ordinal)
: new Dictionary<string, int>(parsed, StringComparer.Ordinal);
}
private static QuestProgressStatus ParseStatus(string raw) =>
raw switch
{
"active" => QuestProgressStatus.Active,
"completed" => QuestProgressStatus.Completed,
_ => throw new InvalidOperationException($"Unknown quest progress status '{raw}'."),
};
private static string ToPersistenceStatus(QuestProgressStatus status) =>
status switch
{
QuestProgressStatus.Active => "active",
QuestProgressStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized) =>
PlayerExists(conn, playerIdNormalized, null);
private static bool PlayerExists(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private delegate bool TryMutateRowDelegate(QuestStepState current, out QuestStepState updated);
}