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 Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode(); 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( var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, TargetId = targetId,
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests
Assert.True(body!.SelectionApplied); 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) private static async Task BindSlot0GuardAsync(HttpClient client)
{ {
var post = await client.PostAsJsonAsync( var post = await client.PostAsJsonAsync(
@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
Assert.True(body3!.Accepted); 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.Diagnostics;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; 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-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-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-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> /// </remarks>
public static class AbilityCastApi public static class AbilityCastApi
{ {
@ -68,6 +71,12 @@ public static class AbilityCastApi
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore, INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore, IPlayerGigProgressionStore gigStore,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
{ {
@ -255,6 +264,20 @@ public static class AbilityCastApi
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant))); 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). // 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). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
return JsonAbilityCast( 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);
}
}
}