From e6818577788ca0e06ead2f12e6263072441acc78 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 15:44:12 -0400 Subject: [PATCH] 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. --- .../Post interact depleted node deny.bru | 62 ++++++ ...ost interact gather then get inventory.bru | 48 ++++ ...nteract prototype resource node gather.bru | 5 +- .../Interaction/InteractablesWorldApiTests.cs | 17 +- ...ctionResourceNodeGatherIntegrationTests.cs | 209 ++++++++++++++++++ .../InteractionResourceNodeGatherXpTests.cs | 38 +--- ...vityDeniedRegistryWebApplicationFactory.cs | 17 +- .../Game/Interaction/InteractionApi.cs | 44 +++- .../PrototypeInteractableRegistry.cs | 21 ++ 9 files changed, 409 insertions(+), 52 deletions(-) create mode 100644 bruno/neon-sprawl-server/interaction/Post interact depleted node deny.bru create mode 100644 bruno/neon-sprawl-server/interaction/Post interact gather then get inventory.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Interaction/InteractionResourceNodeGatherIntegrationTests.cs 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) =>