diff --git a/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru
new file mode 100644
index 0000000..9964c31
--- /dev/null
+++ b/bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru
@@ -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");
+ });
+}
diff --git a/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru b/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru
new file mode 100644
index 0000000..611d5bb
--- /dev/null
+++ b/bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru
@@ -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);
+ });
+}
diff --git a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru
index 4369d5d..87f9581 100644
--- a/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru
+++ b/bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru
@@ -1,12 +1,11 @@
meta {
- name: POST interact - prototype_resource_node_alpha (NEO-41 gather XP)
+ name: POST interact - prototype_resource_node_alpha (NEO-63 gather)
type: http
seq: 11
}
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`.
- Server tests include a stub-registry case where `activity` is disallowed for `salvage` (interact still allowed; XP unchanged).
+ 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.
}
script:pre-request {
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs
index 830468c..6d5e320 100644
--- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractablesWorldApiTests.cs
@@ -25,7 +25,7 @@ public class InteractablesWorldApiTests
Assert.NotNull(body);
Assert.Equal(InteractablesListResponse.CurrentSchemaVersion, body!.SchemaVersion);
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();
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(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);
Assert.Equal("terminal", term.Kind);
Assert.Equal(0, term.Anchor.X);
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs
new file mode 100644
index 0000000..a61c89f
--- /dev/null
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs
@@ -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;
+
+/// NEO-63: resource_node interact delegates to .
+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();
+ 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();
+ Assert.NotNull(bag);
+ Assert.Equal(1, CountItem(bag!, "scrap_metal_bulk"));
+
+ Assert.Equal(HttpStatusCode.OK, progression.StatusCode);
+ var skills = await progression.Content.ReadFromJsonAsync();
+ 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();
+ 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();
+ var skillsBefore = await progressionBefore.Content.ReadFromJsonAsync();
+ 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();
+ Assert.NotNull(envelope);
+ Assert.False(envelope!.Allowed);
+ Assert.Equal(GatherReasonCodes.NodeDepleted, envelope.ReasonCode);
+
+ var bagAfter = await inventoryAfter.Content.ReadFromJsonAsync();
+ var skillsAfter = await progressionAfter.Content.ReadFromJsonAsync();
+ 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();
+ Assert.NotNull(envelope);
+ Assert.True(envelope!.Allowed);
+
+ var bag = await inventory.Content.ReadFromJsonAsync();
+ Assert.NotNull(bag);
+ Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
+
+ var skills = await progression.Content.ReadFromJsonAsync();
+ 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();
+ Assert.NotNull(envelope);
+ Assert.False(envelope!.Allowed);
+ Assert.False(string.IsNullOrEmpty(envelope.ReasonCode));
+
+ var bag = await inventory.Content.ReadFromJsonAsync();
+ Assert.NotNull(bag);
+ Assert.Equal(0, CountItem(bag!, "scrap_metal_bulk"));
+
+ var skills = await progression.Content.ReadFromJsonAsync();
+ 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;
+ }
+}
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs
index 19a682d..5e7507b 100644
--- a/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherXpTests.cs
@@ -9,7 +9,7 @@ using Xunit;
namespace NeonSprawl.Server.Tests.Game.Interaction;
-/// NEO-41: successful resource_node interact grants salvage XP via shared NEO-38 grant path.
+/// NEO-41 legacy coverage retained for terminal control; gather side effects covered by NEO-63 integration tests.
public sealed class InteractionResourceNodeGatherXpTests
{
[Fact]
@@ -79,40 +79,4 @@ public sealed class InteractionResourceNodeGatherXpTests
var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
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();
- Assert.NotNull(envelope);
- Assert.True(envelope!.Allowed);
-
- Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
- var body = await snapshot.Content.ReadFromJsonAsync();
- Assert.NotNull(body);
- var salvage = body!.Skills!.Single(static s => s.Id == "salvage");
- Assert.Equal(0, salvage.Xp);
- }
}
diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs
index 6e2b43c..fabbdb8 100644
--- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs
@@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
+using NeonSprawl.Server.Game.Gathering;
+using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
@@ -15,7 +17,7 @@ namespace NeonSprawl.Server.Tests.Game.Interaction;
///
/// Same in-memory overrides as , plus a stub
-/// where salvage does not allow activity — for NEO-41 defensive tests without mutating disk catalog.
+/// where salvage does not allow activity — for gather deny tests without mutating disk catalog.
///
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory
{
@@ -29,8 +31,17 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"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:MasteryDirectory", masteryDir);
+ builder.UseSetting("Content:ItemsDirectory", itemsDir);
+ builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
+ builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>
{
@@ -40,6 +51,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
+ d.ServiceType == typeof(IPlayerInventoryStore) ||
+ d.ServiceType == typeof(IResourceNodeInstanceStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
@@ -58,6 +71,8 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
index 53d03bc..35f502e 100644
--- a/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
+++ b/server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs
@@ -1,3 +1,5 @@
+using NeonSprawl.Server.Game.Gathering;
+using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
@@ -16,8 +18,10 @@ public static class InteractionApi
app.MapPost(
"/game/players/{id}/interact",
(string id, InteractionRequest? body, IPositionStateStore store,
- ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
- PerkUnlockEngine perkUnlockEngine) =>
+ IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry,
+ IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
+ IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
+ IResourceNodeInstanceStore nodeInstanceStore) =>
{
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))
{
- // NEO-41: prototype gather completion — same grant rules/persistence as POST …/skill-progression.
- // Interact response stays Allowed=true; grant outcome is intentionally ignored (silent no-op on deny / store miss per plan).
- _ = SkillProgressionGrantOperations.TryApplyGrant(
+ var gather = GatherOperations.TryGather(
playerKey,
- GatherSkillXpConstants.SalvageSkillId,
- GatherSkillXpConstants.ActivityXpPerResourceNodeGather,
- GatherSkillXpConstants.ActivitySourceKind,
- registry,
+ lookupKey,
+ nodeRegistry,
+ itemRegistry,
+ inventoryStore,
+ skillRegistry,
xpStore,
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(
diff --git a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
index 1bcf04c..2fd5f55 100644
--- a/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
+++ b/server/NeonSprawl.Server/Game/Interaction/PrototypeInteractableRegistry.cs
@@ -12,6 +12,15 @@ public static class PrototypeInteractableRegistry
/// Second stub row for multi-entity projection (NEO-25).
public const string PrototypeResourceNodeAlphaId = "prototype_resource_node_alpha";
+ /// Subsurface lens gather anchor (NEO-63).
+ public const string PrototypeSubsurfaceVeinBetaId = "prototype_subsurface_vein_beta";
+
+ /// Bio lens gather anchor (NEO-63).
+ public const string PrototypeBioMatGammaId = "prototype_bio_mat_gamma";
+
+ /// Urban bulk lens gather anchor (NEO-63).
+ public const string PrototypeUrbanBulkDeltaId = "prototype_urban_bulk_delta";
+
/// for gather-anchor nodes (NEO-41).
public const string ResourceNodeKind = "resource_node";
@@ -21,10 +30,22 @@ public static class PrototypeInteractableRegistry
/// +X / −Z open floor (avoids overlap with scene Obstacle at ~(6,1,5)); same radius as terminal.
public static readonly PrototypeInteractableEntry PrototypeResourceNodeAlpha = new(12, 0.5, -6, 3.0, ResourceNodeKind);
+ /// 6 m east of alpha on −Z band (NEO-63).
+ public static readonly PrototypeInteractableEntry PrototypeSubsurfaceVeinBeta = new(18, 0.5, -6, 3.0, ResourceNodeKind);
+
+ /// 6 m north (+Z) of alpha (NEO-63).
+ public static readonly PrototypeInteractableEntry PrototypeBioMatGamma = new(12, 0.5, 0, 3.0, ResourceNodeKind);
+
+ /// Northeast open quadrant (NEO-63).
+ public static readonly PrototypeInteractableEntry PrototypeUrbanBulkDelta = new(18, 0.5, 0, 3.0, ResourceNodeKind);
+
private static readonly Dictionary ById = new(StringComparer.Ordinal)
{
[PrototypeTerminalId] = PrototypeTerminal,
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
+ [PrototypeSubsurfaceVeinBetaId] = PrototypeSubsurfaceVeinBeta,
+ [PrototypeBioMatGammaId] = PrototypeBioMatGamma,
+ [PrototypeUrbanBulkDeltaId] = PrototypeUrbanBulkDelta,
};
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>