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
parent
b5306aa933
commit
0cf82b9c0a
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue