NEO-106: Wire encounter progress and completion from ability cast.
parent
fc09808482
commit
f581cf412c
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue