neon-sprawl/docs/reviews/2026-05-10-NEO-42.md

8.2 KiB

Code review: NEO-42 (craft/refine → refine skill XP, prep helper)

Date: 2026-05-10

Scope: Branch NEO-42-craft-refine-skill-xp-activity (commits 20a141b, 15bf598) vs origin/main. Linear issue NEO-42. Diff: 7 files, +377 / -0.

Base: origin/main


Verdict

Approve with nits — Plan-aligned preparatory slice: adds RefineActivitySkillXpGrant + RefineSkillXpConstants (parallel to NEO-41 gather constants), defensive deny-path test factory, and docs (server README, manual QA, implementation plan). No live E3.M2 call site exists yet (module absent), which is explicitly acknowledged in the plan, README, and manual QA. All dotnet test NeonSprawl.sln tests pass (139 total, 3 new under RefineActivitySkillXpGrantTests). Two doc-alignment suggestions and a few nits below.

Summary

This PR adds a thin static helper RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(playerId, registry, xpStore, levelCurve) that delegates to SkillProgressionGrantOperations.TryApplyGrant with refine + activity + 10 XP, plus a constants holder. There is no recipe/craft execution path in the repo to call it from, so the helper is shipped as a known callsite for a future E3.M2 merge. Three xUnit tests use the existing AAA pattern: a happy-path that asserts the in-memory store gained 10 XP under refine, a happy-path that round-trips through GET …/skill-progression, and a deny path using a new RefineActivityDeniedRegistryWebApplicationFactory (refine catalog row without activity in allowedXpSourceKinds). Server README and docs/manual-qa/NEO-42.md document the contract and verification flow via the existing POST …/skill-progression control endpoint. Risk: Low; pure additive surface, server authority unchanged, helper is unreferenced until E3.M2 wires it.

Documentation checked

  • docs/plans/NEO-42-implementation-plan.mdmatches (kickoff clarifications honored, both acceptance checkboxes correspond to landed artifacts, files added/modified list matches diff, helper-then-E3.M2-call shape preserved).
  • docs/decomposition/modules/module_dependency_register.mdmatches for E3.M2 row (kept Planned, defensible since no CraftRequest / RecipeDef / CraftResult implemented); matches for E2.M2 row (already In Progress).
  • docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.mdmatches (post-follow-up: NEO-42 landed (prep) paragraph + backlog text updated). partially matches: the "Implementation snapshot" still asserts "Still backlog within this module: Slice 3 NEO-42, NEO-43"; NEO-42 has landed as a preparatory helper and should get a parallel "NEO-42 landed (prep)" paragraph the way NEO-41 did. See Suggestion 1. Done.
  • docs/decomposition/modules/documentation_and_implementation_alignment.mdmatches (E2.M2 tracking row extended with NEO-42 prep + plan link). partially matches: the E2.M2 tracking-table row enumerates NEO-37 → NEO-41 with landed notes but does not yet add a NEO-42 fragment. See Suggestion 1. Done.
  • docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.mdmatches (Implementation snapshot prep paragraph added). partially matches: doc still has no Implementation snapshot; adding a single bullet noting the preparatory helper + plan would help E3.M2 readers find the wiring point when they later implement CraftResult. See Suggestion 2. Done.
  • docs/manual-qa/NEO-42.mdmatches (book_or_item is correctly outside refine.allowedXpSourceKinds = [activity, mission_reward, trainer] in content/skills/prototype_skills.json; control POST + GET steps reflect the real grant path).
  • server/README.md (diff) — matches plan (Craft / refine hook → skill XP (NEO-42) section).
  • Cross-cutting client_server_authority.md / contracts.mdN/A (no new public surface; helper lives behind future server caller).

Register / tracking: Implementation tracking table E2.M2 row needs a NEO-42 paragraph; register Status fields require no change. E2.M2 row updated; register Status fields unchanged. Done.

Blocking issues

None.

Suggestions

  1. Update E2.M2 implementation-snapshot docs to reflect NEO-42 landing. Two locations: Done.

    • docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md — replace / supplement the line "Still backlog within this module: Slice 3 NEO-42, NEO-43" with a "NEO-42 landed (prep): RefineActivitySkillXpGrant + RefineSkillXpConstants + deny-path factory; awaiting E3.M2 success handler to invoke" paragraph, mirroring the NEO-41 paragraph.
    • docs/decomposition/modules/documentation_and_implementation_alignment.md — extend the E2.M2 tracking row's snapshot column with the equivalent NEO-42 sentence and add the plan link. This keeps the same pattern NEO-41 set in 0a141ba-style follow-ups.
  2. Add an Implementation snapshot to E3_M2_RefinementAndRecipeExecution.md. Status can remain Planned in the register (no CraftRequest / RecipeDef / CraftResult yet), but a one-paragraph snapshot under Summary noting "preparatory helper RefineActivitySkillXpGrant ready for the future success handler; plan: NEO-42" would help the next implementer find the existing wiring point. Mirrors how NEO-25 / NEO-26 noted partial work without prematurely flipping module status. Done.

Nits

  • Nit (test 3 assertion strength): RefineActivitySkillXpGrantTests.GrantOnSuccessfulCraftOrRefine_WhenRefineDisallowsActivity_ShouldLeaveRefineXpZero currently does: Done. (Assert.False(totals.ContainsKey("refine"));)

    var totals = xpStore.GetXpTotals("dev-local-1");
    _ = totals.TryGetValue("refine", out var refineXp);
    Assert.Equal(0, refineXp);
    

    Because TryApplyGrant returns before TryApplyXpDelta on a deny, the refine key should never exist in the store. Assert.False(totals.ContainsKey("refine")); (or Assert.False(totals.TryGetValue("refine", out _));) expresses that semantic exactly and would catch a regression where the store unexpectedly created a 0-valued entry. Current form passes both states.

  • Nit (helper symmetry with gather call site): RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine calls playerId.Trim() before delegating, but the NEO-41 inline call in InteractionApi.cs passes the already-normalized playerKey straight through. Harmless either way (store re-normalizes), but picking one direction across both gather and refine hooks keeps callers consistent for whoever wires E3.M2 next. Done. (Trim removed; <remarks> documents normalized playerId.)

  • Nit (factory duplication): RefineActivityDeniedRegistryWebApplicationFactory is ~90% identical to SalvageActivityDeniedRegistryWebApplicationFactory (only the stub-registry composition and namespace differ). Optional follow-up: extract a small InMemoryFactoryWithSkillRegistryOverride<TRegistry> base in NeonSprawl.Server.Tests to keep future deny-path factories DRY (combat XP, intrusion, etc.). Not worth blocking on for two factories.

  • Nit (acceptance-criteria phrasing): Plan checkbox "Craft/refine completion shall grant skill XP via NEO-38 path" is ticked, but no craft/refine completion path exists yet. The plan acknowledges this in the same bullet ("E3.M2 must call GrantOnSuccessfulCraftOrRefine on success … no craft route in repo yet"). Consider rewording to "Preparatory helper invokes NEO-38 grant path; E3.M2 success handler wiring tracked as follow-up" so future readers don't misread the box as a fully implemented end-to-end behavior. Optional. Done.

Verification

  • dotnet test NeonSprawl.slnpassed (139 tests, including the 3 new RefineActivitySkillXpGrantTests).
  • Manual: follow docs/manual-qa/NEO-42.mdPOST …/movePOST …/skill-progression with {skillId: "refine", sourceKind: "activity", amount: 10} → expect granted: true, then re-POST with sourceKind: "book_or_item" → expect granted: false, reasonCode: "source_kind_not_allowed".
  • After E3.M2 lands: confirm a single call to RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine on the craft-success branch (not on deny / cost-failure) and re-verify via GET …/skill-progression.