6.2 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–38774a5 vs main
Base: main
Follow-up: Addressed review suggestions (retroactive path-auto on branch pick, deny-code tests, unlock rollback, decomposition docs, gather-hook smoke, nits).
Verdict
Approve with nits
Summary
This change delivers server-authoritative perk state (IPlayerPerkStateStore in-memory + Postgres V004), a PerkUnlockEngine with explicit tier-1 branch picks and path-auto tier-2 unlocks for prototype salvage, and a level-up hook inside SkillProgressionGrantOperations.TryApplyGrant wired through HTTP POST, gather interact, and existing XP grant helpers. Tests cover deny paths, reevaluation idempotency, Postgres persistence across factories, and an HTTP grant smoke that crosses level 4 after a tier-1 pick. All 172 server tests pass locally. Overall risk is moderate-low: new persistence tables and grant-path side effects, but no public perk HTTP yet (NEO-48) and prototype catalog remains a single track.
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: update E2.M3 row and module doc when merging (non-blocking doc follow-up).
Blocking issues
None.
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 (recommended before merge)
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.