368 lines
12 KiB
C#
368 lines
12 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 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);
|
|
}
|
|
|
|
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);
|
|
}
|