using System.Text.Json; namespace NeonSprawl.Server.Game.Quests; /// PostgreSQL-backed quest progress keyed by normalized player id + quest id (NEO-116). public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerQuestStateStore { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; /// 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); } /// 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; } /// 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(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; } /// 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(StringComparer.Ordinal), row.CompletedAt); return true; }, out snapshot); } /// 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(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); } /// 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); } 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(2); var completedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3); var counters = DeserializeCounters(countersJson); return new QuestStepState(player, quest, status, stepIndex, counters, completedAt); } private static Dictionary DeserializeCounters(string json) { if (string.IsNullOrWhiteSpace(json) || json == "{}") { return new Dictionary(StringComparer.Ordinal); } var parsed = JsonSerializer.Deserialize>(json, JsonOptions); return parsed is null ? new Dictionary(StringComparer.Ordinal) : new Dictionary(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); }