7.6 KiB
Code review — NEO-47 perk unlock engine + PerkState persistence
Date: 2026-05-17
Scope: Branch NEO-47-perk-unlock-engine-perkstate-persistence · commits e297b3a–0a2a6fe 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 | Verified — ApplyPathAutoTiersAtOrBelowLevel 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 | Verified — TryRemoveBranchPick + deny player_not_found when TryUnlockPerks fails after pick. |
| Decomposition docs | Verified — E2_M3_MasteryAndPerkUnlocks.md NEO-47 handoff; alignment table row updated. |
| Gather-hook smoke | Verified — PostInteract_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
Retroactive path-auto unlock after branch pick —Done.TrySelectBranchpersists 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 thoughrequiredLevelwas already satisfied. NEO-48 branch POST will make this path easy to hit. After a successful pick, callReevaluateAfterLevelUp(playerId, skillId, currentLevel)(level fromGetSkillLevel) 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.ApplyPathAutoTiersAtOrBelowLevelshared by pick + level-up; testTrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant.Deny-code test gaps vs plan — Plan table listsDone. Addedunknown_track,unknown_tier, andplayer_not_found. Tests coverlevel_too_low,branch_already_chosen,unknown_branch, andtier_branch_not_selectablebut not the three above. Small engine unit tests (badskillId, badtierIndex, unknown player id on in-memory store without seed) would lock NEO-48 HTTP mapping.TrySelectBranch_WhenUnknownTrack_*,WhenUnknownTier_*,WhenPlayerNotInStore_*.Done.UnlockPerksstore failure — WhenTryAddUnlockedPerksreturnsfalse, the method returns an empty event list whileTrySelectBranchmay have already committed the branch pick. Harmless for prototype tier 1 (emptyperkIds), but worth surfacingplayer_not_foundon the outcome or rolling back the pick before NEO-48/expanded catalogs add perks on pick tiers.TryUnlockPerksreturns false →TryRemoveBranchPickrollback +player_not_founddeny.Decomposition doc refresh — Mirror NEO-46 follow-up: add NEO-47 handoff toDone.E2_M3_MasteryAndPerkUnlocks.md(engine + stores, no HTTP) and update the E2.M3 row indocumentation_and_implementation_alignment.md.Gather-hook integration smoke — Plan mentionsDone.InteractionApigrant path; implementation wiresperkUnlockEnginecorrectly, but onlyPOST …/skill-progressionis smoke-tested for level-4 unlock. One interact test (or manual QA note in NEO-48) would close the loop for NEO-41 parity.PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook.
Nits
Nit:Done. Resolve singletons fromPerkUnlockEngineTests.CreateEngineopens aIServiceScopewithout disposing; useusing var scope = …or resolve from a single test scope.factory.Services.Nit:Done.ReevaluateAfterLevelUpcallsperkStore.GetSnapshotinside the per-tier loop; a single snapshot per invocation is enough.ApplyPathAutoTiersAtOrBelowLevelrefreshes snapshot only after successful unlock batches.- Nit: Empty
skillId/branchIdonTrySelectBranchmaps tounknown_trackrather 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 onDone. Cross-ref onMasteryCatalogLoaderorTierRequiresExplicitPickpointing toPerkUnlockEngine.TryGetTierIndexGateErrorandPerkUnlockEngineclass 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.