diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs index d405783..8f4d352 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs @@ -16,6 +16,34 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg private const string PlayerId = "dev-local-1"; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero); + [RequirePostgresFact] + public async Task TryActivate_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently() + { + // Arrange + await ResetQuestProgressTableAsync(); + const int concurrentCalls = 8; + var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; + + // Act + var results = await Task.WhenAll( + Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() => + { + using var scope = Factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + return store.TryActivate(PlayerId, questId, out QuestStepState _); + }))); + + // Assert + Assert.Equal(1, results.Count(static r => r)); + Assert.Equal(concurrentCalls - 1, results.Count(static r => !r)); + Assert.True( + Factory.Services.CreateScope().ServiceProvider + .GetRequiredService() + .TryGetProgress(PlayerId, questId, out var snapshot)); + Assert.Equal(QuestProgressStatus.Active, snapshot.Status); + Assert.Equal(0, snapshot.CurrentStepIndex); + } + [RequirePostgresFact] public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory() { diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs index 135c828..2a60b37 100644 --- a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs @@ -68,6 +68,29 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo 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 @@ -82,33 +105,17 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo sel.Parameters.AddWithValue("pid", player); sel.Parameters.AddWithValue("qid", quest); using var reader = sel.ExecuteReader(); - if (reader.Read()) + if (!reader.Read()) { - snapshot = ReadSnapshot(reader, player, quest); tx.Rollback(); return false; } + + snapshot = ReadSnapshot(reader, player, quest); } - 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()); - """, - conn, - tx); - insert.Parameters.AddWithValue("pid", player); - insert.Parameters.AddWithValue("qid", quest); - insert.ExecuteNonQuery(); - tx.Commit(); - snapshot = new QuestStepState( - player, - quest, - QuestProgressStatus.Active, - 0, - new Dictionary(StringComparer.Ordinal), - null); - return true; + tx.Rollback(); + return false; } ///