NEO-42: add refine activity XP grant helper and tests
parent
20a141ba37
commit
15bf598228
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Manual QA — NEO-42 (craft / refine → refine skill XP, `activity`)
|
||||||
|
|
||||||
|
Reference: [implementation plan](../plans/NEO-42-implementation-plan.md), [NEO-38](./NEO-38.md) (skill progression grant), [NEO-41](./NEO-41.md) (gather analogue).
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`).
|
||||||
|
- Player **`dev-local-1`** (default seed).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=http://localhost:5253
|
||||||
|
ID=dev-local-1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated coverage
|
||||||
|
|
||||||
|
`RefineActivitySkillXpGrantTests` exercises the **NEO-38** grant path with disk **`prototype_skills.json`** (`refine` allows **`activity`**) and a stub registry where **`refine`** disallows **`activity`**.
|
||||||
|
|
||||||
|
## Control: direct POST grant (same stack as E3.M2 hook)
|
||||||
|
|
||||||
|
Move once so **`GET …/skill-progression`** is not **404**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "${BASE}/game/players/${ID}/move" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"schemaVersion":1,"target":{"x":0.5,"y":0.9,"z":0.5}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "${BASE}/game/players/${ID}/skill-progression"
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply **`refine`** + **`activity`** (mirrors what **`RefineActivitySkillXpGrant`** does internally):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"schemaVersion":1,"skillId":"refine","amount":10,"sourceKind":"activity"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect **HTTP 200**, **`granted": true`**, and **`refine`** **`xp`** increased (repeat **POST** to see cumulative XP).
|
||||||
|
|
||||||
|
## Deny path (catalog guard)
|
||||||
|
|
||||||
|
With default catalog, **`trainer`** is allowed for **`refine`** but **`book_or_item`** is not:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i -X POST "${BASE}/game/players/${ID}/skill-progression" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"schemaVersion":1,"skillId":"refine","amount":5,"sourceKind":"book_or_item"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect **HTTP 200**, **`granted": false`**, **`reasonCode":"source_kind_not_allowed"`**.
|
||||||
|
|
||||||
|
## E3.M2 follow-up
|
||||||
|
|
||||||
|
When recipe/refine execution exists, confirm **one** server-side call to **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** on **success only**, then **`GET …/skill-progression`** shows expected **`refine`** **`xp`**.
|
||||||
|
|
@ -35,8 +35,12 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Craft/refine completion grants skill XP via **NEO-38** path (`TryApplyGrant` / shared persistence as `POST …/skill-progression`).
|
- [x] Craft/refine completion **shall** grant skill XP via **NEO-38** path — **`RefineActivitySkillXpGrant`** wraps **`TryApplyGrant`** (same stack as **`POST …/skill-progression`**). **E3.M2** must call **`GrantOnSuccessfulCraftOrRefine`** on success (documented in **`server/README.md`** + manual QA); no craft route in repo yet.
|
||||||
- [ ] Grant channels respect **skill catalog** `allowedXpSourceKinds` (deny / no-op when `activity` not allowed).
|
- [x] Grant channels respect **skill catalog** `allowedXpSourceKinds` (stub registry test: **`refine`** without **`activity`** → no XP).
|
||||||
|
|
||||||
|
## Decisions (implementation)
|
||||||
|
|
||||||
|
- **`RefineActivityDeniedRegistryWebApplicationFactory`** added for allowlist-deny coverage (parallel to NEO-41 salvage-denied factory).
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
@ -57,13 +61,15 @@
|
||||||
| `server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs` | NEO-42 prototype constants: `refine`, `activity`, flat XP amount. |
|
| `server/NeonSprawl.Server/Game/Skills/RefineSkillXpConstants.cs` | NEO-42 prototype constants: `refine`, `activity`, flat XP amount. |
|
||||||
| `server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs` | Single entry point calling `TryApplyGrant` for successful craft/refine (prototype). |
|
| `server/NeonSprawl.Server/Game/Skills/RefineActivitySkillXpGrant.cs` | Single entry point calling `TryApplyGrant` for successful craft/refine (prototype). |
|
||||||
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs` | AAA tests: grant when allowed; no XP when `activity` not in allowlist for `refine`. |
|
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivitySkillXpGrantTests.cs` | AAA tests: grant when allowed; no XP when `activity` not in allowlist for `refine`. |
|
||||||
| `docs/manual-qa/NEO-42.md` | Checklist during implementation per repo convention (may be minimal until E3.M2 endpoint exists). |
|
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Test host with **`refine`** catalog row that omits **`activity`**. |
|
||||||
|
| `docs/manual-qa/NEO-42.md` | Curl checklist + E3.M2 follow-up note. |
|
||||||
|
|
||||||
## Files to modify
|
## Files to modify
|
||||||
|
|
||||||
| Path | Rationale |
|
| Path | Rationale |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| **None at kickoff** for application wiring — **E3.M2** success handler file path is unknown until that module exists; add the **`RefineActivitySkillXpGrant`** call there in the same or follow-up PR. If E3.M2 lands before NEO-42 implementation, replace this row with the concrete path (e.g. `…/Crafting/CraftApi.cs`). |
|
| `server/README.md` | Document NEO-42 hook type and E3.M2 integration expectation. |
|
||||||
|
| **E3.M2 craft success handler** (path TBD) | Call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** once on successful craft/refine — **not done in this PR** (module absent). |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same in-memory overrides as <see cref="InMemoryWebApplicationFactory"/>, plus a stub <see cref="ISkillDefinitionRegistry"/>
|
||||||
|
/// where <c>refine</c> does not allow <c>activity</c> — NEO-42 defensive tests without mutating disk catalog.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
for (var i = services.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var d = services[i];
|
||||||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
|
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
||||||
|
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||||
|
(d.ServiceType == typeof(IHostedService) &&
|
||||||
|
(d.ImplementationType == typeof(PostgresDevPlayerSeedHostedService) ||
|
||||||
|
d.ImplementationType == typeof(NpgsqlDataSourceShutdownHostedService))))
|
||||||
|
{
|
||||||
|
services.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 4, 30, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
services.AddSingleton<TimeProvider>(fakeTime);
|
||||||
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
|
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Prototype trio ids; <c>refine</c> excludes <c>activity</c> so craft/refine grant path denies without throwing.</summary>
|
||||||
|
internal sealed class RefineActivityDeniedSkillRegistry : ISkillDefinitionRegistry
|
||||||
|
{
|
||||||
|
private static readonly SkillDefRow Salvage = new(
|
||||||
|
"salvage",
|
||||||
|
"gather",
|
||||||
|
"Salvage",
|
||||||
|
new[] { "activity", "mission_reward" });
|
||||||
|
|
||||||
|
private static readonly SkillDefRow RefineNoActivity = new(
|
||||||
|
"refine",
|
||||||
|
"process",
|
||||||
|
"Refine",
|
||||||
|
new[] { "mission_reward", "trainer" });
|
||||||
|
|
||||||
|
private static readonly SkillDefRow Intrusion = new(
|
||||||
|
"intrusion",
|
||||||
|
"tech",
|
||||||
|
"Intrusion",
|
||||||
|
new[] { "activity", "mission_reward", "book_or_item" });
|
||||||
|
|
||||||
|
private static readonly IReadOnlyList<SkillDefRow> Ordered =
|
||||||
|
[
|
||||||
|
Intrusion,
|
||||||
|
RefineNoActivity,
|
||||||
|
Salvage,
|
||||||
|
];
|
||||||
|
|
||||||
|
public bool TryGetDefinition(string? skillId, [NotNullWhen(true)] out SkillDefRow? definition)
|
||||||
|
{
|
||||||
|
if (skillId is null)
|
||||||
|
{
|
||||||
|
definition = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var k = skillId.Trim();
|
||||||
|
foreach (var row in Ordered)
|
||||||
|
{
|
||||||
|
if (string.Equals(row.Id, k, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
definition = row;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
definition = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<SkillDefRow> GetDefinitionsInIdOrder() => Ordered;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>NEO-42: craft/refine completion hook → <c>refine</c> XP via shared NEO-38 grant path (<c>sourceKind: activity</c>).</summary>
|
||||||
|
public sealed class RefineActivitySkillXpGrantTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldIncreaseRefineXp()
|
||||||
|
{
|
||||||
|
// Arrange — dev player is seeded on the in-memory progression store (NEO-38).
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
Assert.True(totals.TryGetValue("refine", out var xp));
|
||||||
|
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GrantOnSuccessfulCraftOrRefine_WhenCatalogAllowsActivity_ShouldMatchSkillProgressionGet()
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
||||||
|
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, snapshot.StatusCode);
|
||||||
|
var body = await snapshot.Content.ReadFromJsonAsync<SkillProgressionSnapshotResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
var refine = body!.Skills!.Single(static s => s.Id == "refine");
|
||||||
|
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, refine.Xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GrantOnSuccessfulCraftOrRefine_WhenRefineDisallowsActivity_ShouldLeaveRefineXpZero()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new RefineActivityDeniedRegistryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
_ = totals.TryGetValue("refine", out var refineXp);
|
||||||
|
Assert.Equal(0, refineXp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NEO-42: E3.M2 craft/refine success should call <see cref="GrantOnSuccessfulCraftOrRefine"/> so XP uses the same path as
|
||||||
|
/// <c>POST …/skill-progression</c>. Outcome is ignored on success paths (mirror gather interact); use
|
||||||
|
/// <see cref="SkillProgressionGrantOperations.TryApplyGrant"/> directly when callers need deny envelopes.
|
||||||
|
/// </summary>
|
||||||
|
public static class RefineActivitySkillXpGrant
|
||||||
|
{
|
||||||
|
/// <summary>Applies one <c>refine</c> + <c>activity</c> grant if the catalog allowlist permits it.</summary>
|
||||||
|
public static void GrantOnSuccessfulCraftOrRefine(
|
||||||
|
string playerId,
|
||||||
|
ISkillDefinitionRegistry registry,
|
||||||
|
IPlayerSkillProgressionStore xpStore,
|
||||||
|
ISkillLevelCurve levelCurve)
|
||||||
|
{
|
||||||
|
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
||||||
|
playerId.Trim(),
|
||||||
|
RefineSkillXpConstants.RefineSkillId,
|
||||||
|
RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion,
|
||||||
|
RefineSkillXpConstants.ActivitySourceKind,
|
||||||
|
registry,
|
||||||
|
xpStore,
|
||||||
|
levelCurve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
/// <summary>NEO-42: prototype craft/refine completion → <c>refine</c> skill XP via <c>sourceKind: activity</c>.</summary>
|
||||||
|
public static class RefineSkillXpConstants
|
||||||
|
{
|
||||||
|
public const int ActivityXpPerCraftRefineCompletion = 10;
|
||||||
|
|
||||||
|
public const string RefineSkillId = "refine";
|
||||||
|
|
||||||
|
public const string ActivitySourceKind = "activity";
|
||||||
|
}
|
||||||
|
|
@ -178,6 +178,10 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ
|
||||||
|
|
||||||
When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`.
|
When **`allowed` is `true`** and the interactable’s **`kind`** is **`resource_node`** (today: **`prototype_resource_node_alpha`** from **`GET /game/world/interactables`**), the server applies **10** **`salvage`** skill XP with **`sourceKind: activity`** using the **same validation and persistence** as **`POST /game/players/{id}/skill-progression`** ([NEO-38](#skill-progression-grant-neo-38)) via `SkillProgressionGrantOperations` — not a separate client-side XP bump. Move into horizontal range first (anchor **(12, −6)** m on X/Z, radius **3**). Plan: [NEO-41 implementation plan](../../docs/plans/NEO-41-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-41.md`](../../docs/manual-qa/NEO-41.md); Bruno: `bruno/neon-sprawl-server/position/Post move near prototype resource node.bru` then `bruno/neon-sprawl-server/interaction/Post interact prototype resource node gather.bru`.
|
||||||
|
|
||||||
|
### Craft / refine hook → skill XP (NEO-42)
|
||||||
|
|
||||||
|
**E3.M2** recipe/refine success paths should call **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`**, which applies **10** **`refine`** skill XP with **`sourceKind: activity`** through **`SkillProgressionGrantOperations.TryApplyGrant`** (same validation, **`allowedXpSourceKinds`**, and persistence as **[NEO-38](#skill-progression-grant-neo-38)**). There is no separate craft HTTP in NEO-42; verify behavior with **`POST …/skill-progression`** for **`refine`**/**`activity`** until the craft module wires the helper. Plan: [NEO-42 implementation plan](../../docs/plans/NEO-42-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-42.md`](../../docs/manual-qa/NEO-42.md).
|
||||||
|
|
||||||
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
|
Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md).
|
||||||
|
|
||||||
## Interactable descriptors (NEO-25)
|
## Interactable descriptors (NEO-25)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue