NEO-106: Wire encounter progress and completion from ability cast.

pull/145/head
VinPropane 2026-05-31 18:08:28 -04:00
parent fc09808482
commit f581cf412c
4 changed files with 555 additions and 2 deletions

View File

@ -4,7 +4,9 @@ using System.Text;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc;
@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client)
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
await LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
TargetId = targetId,
});
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests
Assert.True(body!.SelectionApplied);
}
private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId)
{
Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry));
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move",
new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector
{
X = entry.X - 1,
Y = 0.9,
Z = entry.Z - 1,
},
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequestForTarget(string targetId) =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = targetId,
};
private static async Task DefeatLockedTargetWithPulseAsync(
HttpClient client,
InMemoryWebApplicationFactory factory,
string targetId)
{
Assert.NotNull(factory.FakeClock);
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
await BindSlot0PulseAsync(client);
await MoveDevPlayerNearNpcAsync(client, targetId);
await LockPrototypeTargetAsync(client, targetId);
var cast = PulseCastRequestForTarget(targetId);
AbilityCastResponse? defeatBody = null;
for (var i = 0; i < 12; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(body);
Assert.True(body!.Accepted, body.ReasonCode);
Assert.NotNull(body.CombatResolution);
if (body.CombatResolution!.TargetDefeated)
{
defeatBody = body;
break;
}
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
Assert.NotNull(defeatBody);
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
}
private static int CountBagItem(PlayerInventorySnapshotResponse 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 async Task BindSlot0GuardAsync(HttpClient client)
{
var post = await client.PostAsJsonAsync(
@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
Assert.True(body3!.Accepted);
}
[Fact]
public async Task PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Assert
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk") + 10,
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token") + 1,
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.True(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Assert
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk"),
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token"),
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldGrantGigXpPerDefeat_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var gigBefore = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
var gigAfter = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Assert
Assert.NotNull(gigBefore);
Assert.NotNull(gigAfter);
var breachBefore = Assert.Single(gigBefore!.Gigs!);
var breachAfter = Assert.Single(gigAfter!.Gigs!);
Assert.Equal(0, breachBefore.Xp);
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount * 3, breachAfter.Xp);
}
}

View File

@ -0,0 +1,251 @@
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 EncounterCombatWiringTests
{
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 TryProcessCastOutcome_ShouldActivateEncounter_WhenDamagingHitWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.True(progress.Started);
Assert.Empty(progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldMarkDefeatWithoutCompletion_WhenOnlyOneTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldGrantLootOnce_WhenThirdTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
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 TryProcessCastOutcome_ShouldNoOp_WhenNpcNotInAnyEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
"unknown_npc_instance",
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out _));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldSkipActivateAndMark_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
_ = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var after);
Assert.Equal(CountBagItem(before!, "scrap_metal_bulk"), CountBagItem(after!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(before!, "contract_handoff_token"), CountBagItem(after!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNotActivate_WhenZeroDamageWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 0,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress) && progress.Started);
}
private static void DefeatNpc(EncounterCombatWiringTestDependencies deps, string npcId)
{
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
npcId,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
}
private static void DefeatAllRequiredTargets(EncounterCombatWiringTestDependencies deps)
{
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
DefeatNpc(deps, EliteNpc);
}
private static EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCombatWiringTestDependencies(
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 sealed record EncounterCombatWiringTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
}

View File

@ -1,6 +1,8 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
@ -18,6 +20,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via <see cref="EncounterCombatWiring"/>.
/// </remarks>
public static class AbilityCastApi
{
@ -68,6 +71,12 @@ public static class AbilityCastApi
INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -255,6 +264,20 @@ public static class AbilityCastApi
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
}
EncounterCombatWiring.TryProcessCastOutcome(
id.Trim(),
lookupKey,
combatResult.DamageDealt,
combatResult.TargetDefeated,
encounterRegistry,
rewardTableRegistry,
encounterProgressStore,
encounterCompletionStore,
itemRegistry,
inventoryStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
// NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit).
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
return JsonAbilityCast(

View File

@ -0,0 +1,91 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// NEO-106: wires ability-cast combat outcomes to encounter activation, defeat progress, and completion grants.
/// Best-effort on the cast accept path — outcome does not change <c>AbilityCastResponse</c> JSON.
/// </summary>
public static class EncounterCombatWiring
{
/// <summary>
/// On damaging hits, activates encounter progress; on lethal hits, records defeat and may grant rewards.
/// </summary>
public static void TryProcessCastOutcome(
string playerId,
string targetNpcInstanceId,
int damageDealt,
bool targetDefeated,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (damageDealt > 0)
{
EncounterProgressOperations.TryActivateOnFirstEngagement(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore);
}
if (!targetDefeated)
{
return;
}
if (!EncounterProgressOperations.TryMarkTargetDefeated(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore))
{
return;
}
if (!EncounterProgressOperations.TryResolveEncounterForNpc(
targetNpcInstanceId,
encounterRegistry,
out var encounterId,
out _))
{
return;
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
encounterId,
encounterRegistry,
progressStore))
{
return;
}
var completion = EncounterCompletionOperations.TryCompleteAndGrant(
playerId,
encounterId,
encounterRegistry,
rewardTableRegistry,
progressStore,
completionStore,
itemRegistry,
inventoryStore,
timeProvider);
if (!completion.Success)
{
logger?.LogDebug(
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
playerId,
encounterId,
completion.ReasonCode);
}
}
}