NEO-63: wire interact to gather engine and expand registry.
Four resource_node anchors; gather denies map to InteractionResponse; integration tests, Bruno gather/inventory and depletion flows.pull/97/head
parent
f58ab4a2eb
commit
e681857778
|
|
@ -0,0 +1,62 @@
|
||||||
|
meta {
|
||||||
|
name: POST interact depleted node deny
|
||||||
|
type: http
|
||||||
|
seq: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-63: pre-request exhausts prototype_resource_node_alpha capacity (10 successful gathers), then this interact should deny with node_depleted.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const axios = require("axios");
|
||||||
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/move`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
target: { x: 10, y: 0.9, z: -6 },
|
||||||
|
},
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/interact`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
interactableId: "prototype_resource_node_alpha",
|
||||||
|
},
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
if (!res.data.allowed) {
|
||||||
|
throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/interact
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
content-type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"interactableId": "prototype_resource_node_alpha"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("depleted node interact denied with node_depleted", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.allowed).to.equal(false);
|
||||||
|
expect(body.reasonCode).to.equal("node_depleted");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
meta {
|
||||||
|
name: POST interact gather then GET inventory
|
||||||
|
type: http
|
||||||
|
seq: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-63 happy path: move near prototype_resource_node_alpha, interact once, then GET inventory and assert scrap_metal_bulk quantity >= 1.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const axios = require("axios");
|
||||||
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/move`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
target: { x: 10, y: 0.9, z: -6 },
|
||||||
|
},
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/interact`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
interactableId: "prototype_resource_node_alpha",
|
||||||
|
},
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/game/players/{{playerId}}/inventory
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("inventory includes scrap_metal_bulk after gather interact", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
const body = res.getBody();
|
||||||
|
const total = body.bagSlots
|
||||||
|
.filter((s) => s.itemId === "scrap_metal_bulk")
|
||||||
|
.reduce((sum, s) => sum + s.quantity, 0);
|
||||||
|
expect(total).to.be.at.least(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
meta {
|
meta {
|
||||||
name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP)
|
name: POST interact - prototype_resource_node_alpha (NEO-63 gather)
|
||||||
type: http
|
type: http
|
||||||
seq: 11
|
seq: 11
|
||||||
}
|
}
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-41: successful interact on `resource_node` kind grants `salvage` XP (`sourceKind: activity`, 10 XP) via same server path as POST skill-progression. Pre-request moves the dev player in horizontal range of the node so this request passes when run alone or when global `seq` order would otherwise leave the player far from the anchor. Optional: GET skill-progression to read `salvage.xp`.
|
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
|
||||||
Server tests include a stub-registry case where `activity` is disallowed for `salvage` (interact still allowed; XP unchanged).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ public class InteractablesWorldApiTests
|
||||||
Assert.NotNull(body);
|
Assert.NotNull(body);
|
||||||
Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
Assert.NotNull(body.Interactables);
|
Assert.NotNull(body.Interactables);
|
||||||
Assert.True(body.Interactables.Count >= 2);
|
Assert.Equal(5, body.Interactables.Count);
|
||||||
|
|
||||||
var ids = body.Interactables.Select(static x => x.InteractableId).ToList();
|
var ids = body.Interactables.Select(static x => x.InteractableId).ToList();
|
||||||
Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids);
|
Assert.Equal(ids.OrderBy(static x => x, StringComparer.Ordinal).ToList(), ids);
|
||||||
|
|
@ -37,6 +37,21 @@ public class InteractablesWorldApiTests
|
||||||
Assert.Equal(-6, alpha.Anchor.Z);
|
Assert.Equal(-6, alpha.Anchor.Z);
|
||||||
Assert.Equal(3.0, alpha.InteractionRadius);
|
Assert.Equal(3.0, alpha.InteractionRadius);
|
||||||
|
|
||||||
|
var beta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeSubsurfaceVeinBetaId);
|
||||||
|
Assert.Equal("resource_node", beta.Kind);
|
||||||
|
Assert.Equal(18, beta.Anchor.X);
|
||||||
|
Assert.Equal(-6, beta.Anchor.Z);
|
||||||
|
|
||||||
|
var gamma = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeBioMatGammaId);
|
||||||
|
Assert.Equal("resource_node", gamma.Kind);
|
||||||
|
Assert.Equal(12, gamma.Anchor.X);
|
||||||
|
Assert.Equal(0, gamma.Anchor.Z);
|
||||||
|
|
||||||
|
var delta = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeUrbanBulkDeltaId);
|
||||||
|
Assert.Equal("resource_node", delta.Kind);
|
||||||
|
Assert.Equal(18, delta.Anchor.X);
|
||||||
|
Assert.Equal(0, delta.Anchor.Z);
|
||||||
|
|
||||||
var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId);
|
var term = body.Interactables.Single(x => x.InteractableId == PrototypeInteractableRegistry.PrototypeTerminalId);
|
||||||
Assert.Equal("terminal", term.Kind);
|
Assert.Equal("terminal", term.Kind);
|
||||||
Assert.Equal(0, term.Anchor.X);
|
Assert.Equal(0, term.Anchor.X);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
|
using NeonSprawl.Server.Game.Interaction;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Game.World;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Interaction;
|
||||||
|
|
||||||
|
/// <summary>NEO-63: <c>resource_node</c> interact delegates to <see cref="GatherOperations"/>.</summary>
|
||||||
|
public sealed class InteractionResourceNodeGatherIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PostInteract_ResourceNode_WhenInRange_ShouldGrantInventoryAndSalvageXp()
|
||||||
|
{
|
||||||
|
// Arrange — node at (12, -6), radius 3; stand at (10, -6) → horizontal distance 2
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await MoveNearAlphaAsync(client);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var interact = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/interact",
|
||||||
|
new InteractionRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||||
|
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||||
|
});
|
||||||
|
var inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||||
|
var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
||||||
|
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||||
|
Assert.NotNull(envelope);
|
||||||
|
Assert.True(envelope!.Allowed);
|
||||||
|
Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, envelope.InteractableId);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, inventory.StatusCode);
|
||||||
|
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||||
|
Assert.NotNull(bag);
|
||||||
|
Assert.Equal(1, CountItem(bag!, "scrap_metal_bulk"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, progression.StatusCode);
|
||||||
|
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
Assert.NotNull(skills);
|
||||||
|
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||||
|
Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostInteract_ResourceNode_WhenDepleted_ShouldDenyWithNodeDepletedWithoutGrants()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await MoveNearAlphaAsync(client);
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var gather = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/interact",
|
||||||
|
new InteractionRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||||
|
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, gather.StatusCode);
|
||||||
|
var body = await gather.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.True(body!.Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var inventoryBefore = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||||
|
var progressionBefore = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
var bagBefore = await inventoryBefore.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||||
|
var skillsBefore = await progressionBefore.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
var scrapBefore = CountItem(bagBefore!, "scrap_metal_bulk");
|
||||||
|
var salvageBefore = skillsBefore!.Skills!.Single(static s => s.Id == "salvage").Xp;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var interact = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/interact",
|
||||||
|
new InteractionRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||||
|
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||||
|
});
|
||||||
|
var inventoryAfter = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||||
|
var progressionAfter = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
||||||
|
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||||
|
Assert.NotNull(envelope);
|
||||||
|
Assert.False(envelope!.Allowed);
|
||||||
|
Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode);
|
||||||
|
|
||||||
|
var bagAfter = await inventoryAfter.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||||
|
var skillsAfter = await progressionAfter.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
Assert.Equal(scrapBefore, CountItem(bagAfter!, "scrap_metal_bulk"));
|
||||||
|
Assert.Equal(
|
||||||
|
salvageBefore,
|
||||||
|
skillsAfter!.Skills!.Single(static s => s.Id == "salvage").Xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostInteract_PrototypeTerminal_ShouldNotGrantInventoryOrSalvageXp()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var move = new MoveCommandRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||||
|
Target = new PositionVector { X = 0.5, Y = 0.9, Z = 0.5 },
|
||||||
|
};
|
||||||
|
await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var interact = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/interact",
|
||||||
|
new InteractionRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||||
|
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
|
||||||
|
});
|
||||||
|
var inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||||
|
var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
||||||
|
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||||
|
Assert.NotNull(envelope);
|
||||||
|
Assert.True(envelope!.Allowed);
|
||||||
|
|
||||||
|
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||||
|
Assert.NotNull(bag);
|
||||||
|
Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
|
||||||
|
|
||||||
|
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
Assert.NotNull(skills);
|
||||||
|
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||||
|
Assert.Equal(0, salvage.Xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldDenyWithoutGrants()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await MoveNearAlphaAsync(client);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var interact = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/interact",
|
||||||
|
new InteractionRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||||
|
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||||
|
});
|
||||||
|
var inventory = await client.GetAsync("/game/players/dev-local-1/inventory");
|
||||||
|
var progression = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
||||||
|
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
||||||
|
Assert.NotNull(envelope);
|
||||||
|
Assert.False(envelope!.Allowed);
|
||||||
|
Assert.False(string.IsNullOrEmpty(envelope.ReasonCode));
|
||||||
|
|
||||||
|
var bag = await inventory.Content.ReadFromJsonAsync<PlayerInventorySnapshotResponse>();
|
||||||
|
Assert.NotNull(bag);
|
||||||
|
Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
|
||||||
|
|
||||||
|
var skills = await progression.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
Assert.NotNull(skills);
|
||||||
|
var salvage = skills!.Skills!.Single(static s => s.Id == "salvage");
|
||||||
|
Assert.Equal(0, salvage.Xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MoveNearAlphaAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
var move = new MoveCommandRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||||
|
Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
|
||||||
|
};
|
||||||
|
await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountItem(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ using Xunit;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.Interaction;
|
namespace NeonSprawl.Server.Tests.Game.Interaction;
|
||||||
|
|
||||||
/// <summary>NEO-41: successful <c>resource_node</c> interact grants <c>salvage</c> XP via shared NEO-38 grant path.</summary>
|
/// <summary>NEO-41 legacy coverage retained for terminal control; gather side effects covered by NEO-63 integration tests.</summary>
|
||||||
public sealed class InteractionResourceNodeGatherXpTests
|
public sealed class InteractionResourceNodeGatherXpTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -79,40 +79,4 @@ public sealed class InteractionResourceNodeGatherXpTests
|
||||||
var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
|
var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
|
||||||
Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp);
|
Assert.Equal(GatherSkillXpConstants.ActivityXpPerResourceNodeGather, salvage.Xp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PostInteract_ResourceNode_WhenSalvageDisallowsActivity_ShouldStillAllowInteract_AndNotApplyXp()
|
|
||||||
{
|
|
||||||
// Arrange — stub registry: salvage exists but activity is not in allowedXpSourceKinds (defensive / corrupt-catalog stand-in).
|
|
||||||
await using var factory = new SalvageActivityDeniedRegistryWebApplicationFactory();
|
|
||||||
var client = factory.CreateClient();
|
|
||||||
var move = new MoveCommandRequest
|
|
||||||
{
|
|
||||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
|
||||||
Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
|
|
||||||
};
|
|
||||||
await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var interact = await client.PostAsJsonAsync(
|
|
||||||
"/game/players/dev-local-1/interact",
|
|
||||||
new InteractionRequest
|
|
||||||
{
|
|
||||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
|
||||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
|
||||||
});
|
|
||||||
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(HttpStatusCode.OK, interact.StatusCode);
|
|
||||||
var envelope = await interact.Content.ReadFromJsonAsync<InteractionResponse>();
|
|
||||||
Assert.NotNull(envelope);
|
|
||||||
Assert.True(envelope!.Allowed);
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
|
|
||||||
var body = await snapshot.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
|
||||||
Assert.NotNull(body);
|
|
||||||
var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
|
|
||||||
Assert.Equal(0, salvage.Xp);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -15,7 +17,7 @@ namespace NeonSprawl.Server.Tests.Game.Interaction;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Same in-memory overrides as <see cref="InMemoryWebApplicationFactory"/>, plus a stub <see cref="ISkillDefinitionRegistry"/>
|
/// Same in-memory overrides as <see cref="InMemoryWebApplicationFactory"/>, plus a stub <see cref="ISkillDefinitionRegistry"/>
|
||||||
/// where <c>salvage</c> does not allow <c>activity</c> — for NEO-41 defensive tests without mutating disk catalog.
|
/// where <c>salvage</c> does not allow <c>activity</c> — for gather deny tests without mutating disk catalog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
|
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
|
|
@ -29,8 +31,17 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +51,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||||
|
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
d.ServiceType == typeof(TimeProvider) ||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
|
|
@ -58,6 +71,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||||
|
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -16,8 +18,10 @@ public static class InteractionApi
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/interact",
|
"/game/players/{id}/interact",
|
||||||
(string id, InteractionRequest? body, IPositionStateStore store,
|
(string id, InteractionRequest? body, IPositionStateStore store,
|
||||||
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry,
|
||||||
PerkUnlockEngine perkUnlockEngine) =>
|
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||||
|
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
|
||||||
|
IResourceNodeInstanceStore nodeInstanceStore) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -72,17 +76,37 @@ public static class InteractionApi
|
||||||
|
|
||||||
if (string.Equals(entry.Kind, PrototypeInteractableRegistry.ResourceNodeKind, StringComparison.Ordinal))
|
if (string.Equals(entry.Kind, PrototypeInteractableRegistry.ResourceNodeKind, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
// NEO-41: prototype gather completion — same grant rules/persistence as POST …/skill-progression.
|
var gather = GatherOperations.TryGather(
|
||||||
// Interact response stays Allowed=true; grant outcome is intentionally ignored (silent no-op on deny / store miss per plan).
|
|
||||||
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
|
||||||
playerKey,
|
playerKey,
|
||||||
GatherSkillXpConstants.SalvageSkillId,
|
lookupKey,
|
||||||
GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
|
nodeRegistry,
|
||||||
GatherSkillXpConstants.ActivitySourceKind,
|
itemRegistry,
|
||||||
registry,
|
inventoryStore,
|
||||||
|
skillRegistry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve,
|
levelCurve,
|
||||||
perkUnlockEngine);
|
perkUnlockEngine,
|
||||||
|
nodeInstanceStore);
|
||||||
|
|
||||||
|
if (!gather.Success)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new InteractionResponse
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
|
||||||
|
Allowed = false,
|
||||||
|
ReasonCode = gather.ReasonCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(
|
||||||
|
new InteractionResponse
|
||||||
|
{
|
||||||
|
SchemaVersion = InteractionResponse.CurrentSchemaVersion,
|
||||||
|
Allowed = true,
|
||||||
|
InteractableId = lookupKey,
|
||||||
|
Payload = new InteractionPlaceholderPayload(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,15 @@ public static class PrototypeInteractableRegistry
|
||||||
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
|
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
|
||||||
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
|
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
|
||||||
|
|
||||||
|
/// <summary>Subsurface lens gather anchor (NEO-63).</summary>
|
||||||
|
public const string PrototypeSubsurfaceVeinBetaId = "prototype_subsurface_vein_beta";
|
||||||
|
|
||||||
|
/// <summary>Bio lens gather anchor (NEO-63).</summary>
|
||||||
|
public const string PrototypeBioMatGammaId = "prototype_bio_mat_gamma";
|
||||||
|
|
||||||
|
/// <summary>Urban bulk lens gather anchor (NEO-63).</summary>
|
||||||
|
public const string PrototypeUrbanBulkDeltaId = "prototype_urban_bulk_delta";
|
||||||
|
|
||||||
/// <summary><see cref="PrototypeInteractableEntry.Kind"/> for gather-anchor nodes (NEO-41).</summary>
|
/// <summary><see cref="PrototypeInteractableEntry.Kind"/> for gather-anchor nodes (NEO-41).</summary>
|
||||||
public const string ResourceNodeKind = "resource_node";
|
public const string ResourceNodeKind = "resource_node";
|
||||||
|
|
||||||
|
|
@ -21,10 +30,22 @@ public static class PrototypeInteractableRegistry
|
||||||
/// <summary>+X / −Z open floor (avoids overlap with scene <c>Obstacle</c> at ~(6,1,5)); same radius as terminal.</summary>
|
/// <summary>+X / −Z open floor (avoids overlap with scene <c>Obstacle</c> at ~(6,1,5)); same radius as terminal.</summary>
|
||||||
public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind);
|
public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind);
|
||||||
|
|
||||||
|
/// <summary>6 m east of alpha on −Z band (NEO-63).</summary>
|
||||||
|
public static readonly PrototypeInteractableEntry PrototypeSubsurfaceVeinBeta = new(18, 0.5, -6, 3.0, ResourceNodeKind);
|
||||||
|
|
||||||
|
/// <summary>6 m north (+Z) of alpha (NEO-63).</summary>
|
||||||
|
public static readonly PrototypeInteractableEntry PrototypeBioMatGamma = new(12, 0.5, 0, 3.0, ResourceNodeKind);
|
||||||
|
|
||||||
|
/// <summary>Northeast open quadrant (NEO-63).</summary>
|
||||||
|
public static readonly PrototypeInteractableEntry PrototypeUrbanBulkDelta = new(18, 0.5, 0, 3.0, ResourceNodeKind);
|
||||||
|
|
||||||
private static readonly Dictionary<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
|
private static readonly Dictionary<string, PrototypeInteractableEntry> ById = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
[PrototypeTerminalId] = PrototypeTerminal,
|
[PrototypeTerminalId] = PrototypeTerminal,
|
||||||
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
|
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
|
||||||
|
[PrototypeSubsurfaceVeinBetaId] = PrototypeSubsurfaceVeinBeta,
|
||||||
|
[PrototypeBioMatGammaId] = PrototypeBioMatGamma,
|
||||||
|
[PrototypeUrbanBulkDeltaId] = PrototypeUrbanBulkDelta,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>
|
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue