NEO-105: Add encounter completion operations and inventory grants.

TryCompleteAndGrant applies reward-table fixed grants with pre-flight
simulate, compensating rollback, and idempotent completion marking.
Returns EncounterCompleteEvent payload for NEO-107 persistence.
pull/144/head
VinPropane 2026-05-31 16:57:53 -04:00
parent b5306aa933
commit 0cf82b9c0a
6 changed files with 563 additions and 0 deletions

View File

@ -0,0 +1,334 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(2, result.GrantsApplied.Count);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1);
Assert.NotNull(result.CompleteEvent);
Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId);
Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId);
Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId);
Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey);
Assert.NotNull(result.CompletedAt);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
var first = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
// Act
var second = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond);
Assert.True(first.Success);
Assert.False(second.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode);
Assert.Empty(second.GrantsApplied);
Assert.Null(second.CompleteEvent);
Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
failingCompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
"unknown_encounter",
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode);
}
private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps)
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
EliteNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
private static void FillBag(EncounterCompletionTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCompletionTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private sealed record EncounterCompletionTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{
public bool IsCompleted(string playerId, string encounterId) =>
inner.IsCompleted(playerId, encounterId);
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false;
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) =>
inner.TryGetCompletedAt(playerId, encounterId, out completedAt);
}
}

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Internal completion payload returned on successful encounter grant (NEO-105).
/// Persistence and E7.M2 quest-credit routing land in NEO-107.
/// </summary>
public readonly record struct EncounterCompleteEvent(
string PlayerId,
string EncounterId,
string RewardTableId,
IReadOnlyList<EncounterGrantApplied> GrantedItems,
DateTimeOffset CompletedAt,
string IdempotencyKey);

View File

@ -0,0 +1,172 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
/// </summary>
public static class EncounterCompletionOperations
{
private static readonly EncounterGrantApplied[] EmptyGrants = [];
/// <summary>
/// When all required targets are defeated and the encounter is not yet completed, applies
/// <paramref name="encounterId"/>'s reward-table fixed grants then marks completion.
/// </summary>
public static EncounterCompletionResult TryCompleteAndGrant(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition))
{
return Deny(EncounterCompletionReasonCodes.UnknownEncounter);
}
if (completionStore.IsCompleted(playerId, normalizedEncounterId))
{
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
normalizedEncounterId,
encounterRegistry,
progressStore))
{
return Deny(EncounterCompletionReasonCodes.NotReady);
}
if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable))
{
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
var grants = rewardTable.FixedGrants;
if (grants.Count == 0)
{
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
{
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
var simulated = CloneSnapshot(snapshot);
foreach (var grant in grants)
{
if (!PlayerInventoryOperations.TrySimulateAddStack(
ref simulated,
grant.ItemId,
grant.Quantity,
itemRegistry))
{
return Deny(EncounterCompletionReasonCodes.InventoryFull);
}
}
var applied = new List<EncounterGrantApplied>(grants.Count);
foreach (var grant in grants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(MapInventoryReason(addOutcome.ReasonCode));
}
applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity));
}
var completedAt = timeProvider.GetUtcNow();
if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt))
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook).
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(
normalizedPlayerId,
normalizedEncounterId,
rewardTable.Id,
applied,
completedAt,
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,
GrantsApplied: applied,
CompleteEvent: completeEvent,
CompletedAt: completedAt);
}
private static string MapInventoryReason(string? reasonCode) =>
reasonCode switch
{
PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull,
PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem,
PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity,
_ => reasonCode ?? EncounterCompletionReasonCodes.InventoryFull,
};
private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) =>
$"{normalizedPlayerId}:{normalizedEncounterId}";
private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) =>
new()
{
BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots),
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots),
};
private static void CompensatingRemoveGrants(
string playerId,
IReadOnlyList<EncounterGrantApplied> grants,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore)
{
for (var i = grants.Count - 1; i >= 0; i--)
{
var grant = grants[i];
_ = PlayerInventoryOperations.TryRemoveStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
}
}
private static EncounterCompletionResult Deny(string reasonCode) =>
new(
Success: false,
ReasonCode: reasonCode,
GrantsApplied: EmptyGrants,
CompleteEvent: null,
CompletedAt: null);
}

View File

@ -0,0 +1,26 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Stable deny reason codes for encounter completion operations (NEO-105).</summary>
public static class EncounterCompletionReasonCodes
{
public const string AlreadyCompleted = "already_completed";
public const string NotReady = "not_ready";
public const string UnknownEncounter = "unknown_encounter";
public const string UnknownRewardTable = "unknown_reward_table";
public const string InventoryStoreMissing = "inventory_store_missing";
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidItem"/>.</summary>
public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidQuantity"/>.</summary>
public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity;
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Server-internal encounter completion resolution envelope (NEO-105).
/// Combat wiring promotes via NEO-106; HTTP DTOs via NEO-108.
/// </summary>
/// <param name="GrantsApplied">Item grants on success; empty when denied.</param>
/// <param name="CompleteEvent">Completion payload on success; null when denied.</param>
public readonly record struct EncounterCompletionResult(
bool Success,
string? ReasonCode,
IReadOnlyList<EncounterGrantApplied> GrantsApplied,
EncounterCompleteEvent? CompleteEvent,
DateTimeOffset? CompletedAt);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>One item grant applied on a successful encounter completion (NEO-105).</summary>
public readonly record struct EncounterGrantApplied(string ItemId, int Quantity);