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
VinPropane 2026-05-24 15:44:12 -04:00
parent f58ab4a2eb
commit e681857778
9 changed files with 409 additions and 52 deletions

View File

@ -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");
});
}

View File

@ -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);
});
}

View File

@ -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 {

View File

@ -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);

View File

@ -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;
}
}

View File

@ -9,7 +9,7 @@ using Xunit;
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
{
[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<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);
}
}

View File

@ -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;
/// <summary>
/// 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>
public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
{
@ -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<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();

View File

@ -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(

View File

@ -12,6 +12,15 @@ public static class PrototypeInteractableRegistry
/// <summary>Second stub row for multi-entity projection (NEO-25).</summary>
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>
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>
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)
{
[PrototypeTerminalId] = PrototypeTerminal,
[PrototypeResourceNodeAlphaId] = PrototypeResourceNodeAlpha,
[PrototypeSubsurfaceVeinBetaId] = PrototypeSubsurfaceVeinBeta,
[PrototypeBioMatGammaId] = PrototypeBioMatGamma,
[PrototypeUrbanBulkDeltaId] = PrototypeUrbanBulkDelta,
};
public static bool TryGet(string interactableIdLowercase, out PrototypeInteractableEntry entry) =>