neon-sprawl/docs/reviews/2026-05-17-NEO-47.md

7.6 KiB
Raw Blame History

Code review — NEO-47 perk unlock engine + PerkState persistence

Date: 2026-05-17
Scope: Branch NEO-47-perk-unlock-engine-perkstate-persistence · commits e297b3a0a2a6fe vs main
Base: main

Follow-up: Addressed review suggestions in 0a2a6fe (retroactive path-auto on branch pick, deny-code tests, unlock rollback, decomposition docs, gather-hook smoke, nits).

Re-review (2026-05-17): Verified follow-up commit against prior suggestions; no blocking regressions; 177 server tests pass.

Verdict

Approve

Summary

Server-authoritative perk state (IPlayerPerkStateStore in-memory + Postgres V004), PerkUnlockEngine (tier-1 picks, path-auto tier-2, retroactive path-auto after pick at current level), and level-up re-evaluation in SkillProgressionGrantOperations (HTTP POST, gather interact, mission/refine helpers). Follow-up commit adds ApplyPathAutoTiersAtOrBelowLevel, branch-pick rollback on perk write failure, CanWritePlayer / TryRemoveBranchPick, expanded deny tests, gather-hook smoke, and E2.M3 doc alignment. All 177 server tests pass locally. Ready for merge from a code perspective; perk HTTP remains NEO-48.

Documentation checked

Document Result
docs/plans/NEO-47-implementation-plan.md Matches — store, engine, V004, hook site, reason codes, acceptance checklist marked complete; tier-1 pick / tier-2 auto-path behavior implemented as adopted kickoff decisions.
docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md Matches — NEO-47 handoff + Status updated after follow-up.
docs/decomposition/modules/documentation_and_implementation_alignment.md Matches — E2.M3 row notes NEO-47 landed after follow-up.
docs/decomposition/modules/client_server_authority.md Matches — unlock evaluation and persistence remain server-side; client unchanged until NEO-48.
docs/plans/NEO-46-implementation-plan.md Matches — engine consumes IMasteryCatalogRegistry and repo catalog; no loader regression in diff.

Register/tracking: E2.M3 module doc and alignment row updated in follow-up (0a2a6fe).

Blocking issues

None (initial review and re-review).

Re-review (2026-05-17)

Prior item Status
Retroactive path-auto after branch pick VerifiedApplyPathAutoTiersAtOrBelowLevel called from TrySelectBranch; test TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant.
Deny-code tests (unknown_track, unknown_tier, player_not_found) Verified — three new [Fact] methods in PerkUnlockEngineTests.cs.
Unlock rollback on store failure VerifiedTryRemoveBranchPick + deny player_not_found when TryUnlockPerks fails after pick.
Decomposition docs VerifiedE2_M3_MasteryAndPerkUnlocks.md NEO-47 handoff; alignment table row updated.
Gather-hook smoke VerifiedPostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook (440 + 10 XP gather).
Test scope / nits Verified — singleton resolve from factory.Services; loader cross-ref on TryGetTierIndexGateError.

New issues from follow-up: None blocking. Optional nit retained below (unknown_track for empty ids). Minor note: path-auto unlock events emitted from TrySelectBranch use PerkUnlockSource.LevelUp (same as reevaluate path) — acceptable for NEO-49 to refine if telemetry needs a distinct source for retroactive unlocks.

Suggestions

  1. Retroactive path-auto unlock after branch pickTrySelectBranch persists tier-1 picks but does not re-run path-auto evaluation. A player who reaches salvage level 4+ before choosing tier 1 will not receive tier-2 perks until a later level-up, even though requiredLevel was already satisfied. NEO-48 branch POST will make this path easy to hit. After a successful pick, call ReevaluateAfterLevelUp(playerId, skillId, currentLevel) (level from GetSkillLevel) or extract shared “apply path-auto tiers ≤ level” logic. Add a test: XP to 450+, pick tier 1, assert tier-2 perk without another grant. Done. ApplyPathAutoTiersAtOrBelowLevel shared by pick + level-up; test TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant.
  2. Deny-code test gaps vs plan — Plan table lists unknown_track, unknown_tier, and player_not_found. Tests cover level_too_low, branch_already_chosen, unknown_branch, and tier_branch_not_selectable but not the three above. Small engine unit tests (bad skillId, bad tierIndex, unknown player id on in-memory store without seed) would lock NEO-48 HTTP mapping. Done. Added TrySelectBranch_WhenUnknownTrack_*, WhenUnknownTier_*, WhenPlayerNotInStore_*.
  3. UnlockPerks store failure — When TryAddUnlockedPerks returns false, the method returns an empty event list while TrySelectBranch may have already committed the branch pick. Harmless for prototype tier 1 (empty perkIds), but worth surfacing player_not_found on the outcome or rolling back the pick before NEO-48/expanded catalogs add perks on pick tiers. Done. TryUnlockPerks returns false → TryRemoveBranchPick rollback + player_not_found deny.
  4. Decomposition doc refresh — Mirror NEO-46 follow-up: add NEO-47 handoff to E2_M3_MasteryAndPerkUnlocks.md (engine + stores, no HTTP) and update the E2.M3 row in documentation_and_implementation_alignment.md. Done.
  5. Gather-hook integration smoke — Plan mentions InteractionApi grant path; implementation wires perkUnlockEngine correctly, but only POST …/skill-progression is smoke-tested for level-4 unlock. One interact test (or manual QA note in NEO-48) would close the loop for NEO-41 parity. Done. PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook.

Nits

  • Nit: PerkUnlockEngineTests.CreateEngine opens a IServiceScope without disposing; use using var scope = … or resolve from a single test scope. Done. Resolve singletons from factory.Services.
  • Nit: ReevaluateAfterLevelUp calls perkStore.GetSnapshot inside the per-tier loop; a single snapshot per invocation is enough. Done. ApplyPathAutoTiersAtOrBelowLevel refreshes snapshot only after successful unlock batches.
  • Nit: Empty skillId / branchId on TrySelectBranch maps to unknown_track rather than a dedicated validation code — acceptable if NEO-48 trims inputs first.
  • Nit: Plan risk “loader comment cross-ref to engine tier rule” not added; optional comment on MasteryCatalogLoader or TierRequiresExplicitPick pointing to PerkUnlockEngine. Done. Cross-ref on TryGetTierIndexGateError and PerkUnlockEngine class summary.

Verification

# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkUnlock|FullyQualifiedName~PerkState"

# Full server suite (177 tests at re-review)
dotnet test NeonSprawl.sln

# Postgres integration (when ConnectionStrings__NeonSprawl is set)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkStatePersistenceIntegration"

Manual: with Postgres, confirm V004 tables exist after first perk write; grant salvage XP 100 → select tier-1 branch (future NEO-48) → grant to 450+ and confirm internal perk state via snapshot store or DB query until HTTP lands.