13 KiB
NEO-47 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-47 |
| Title | E2.M3: Perk unlock engine + PerkState persistence |
| Linear | https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence |
| Module | E2.M3 — MasteryAndPerkUnlocks · Epic 2 Slice 4 |
| Branch | NEO-47-perk-unlock-engine-perkstate-persistence |
| Blocked by | NEO-46 (catalog load — Done on main), NEO-38 (skill level source — Done on main) |
Kickoff clarifications
| Topic | Question | Answer |
|---|---|---|
| Tier 2 branch behavior | At level 4, does the player pick a branch again or do tier-2 perks unlock automatically from the tier-1 choice? | User did not understand the question at kickoff → Agent recommendation (adopted for this story): auto-path — tier 1 is the only explicit branch pick; when skill level reaches tier 2’s requiredLevel (4 for salvage), perks on the same branchId as the tier-1 pick unlock with no second pick. Tier 2 branch rows stay in the catalog for metadata/perks; TrySelectBranch denies with tier_branch_not_selectable if all branches in that tier carry perks (see rule below). |
| Postgres schema | Normalized tables vs JSON blob vs defer Postgres? | User asked for recommendation → Agent recommendation (adopted): normalized tables + Flyway V004, mirroring NEO-38 (player_mastery_branch_pick, player_unlocked_perk). Linear AC requires in-memory + Postgres in this story. |
| PerkUnlockEvent surfacing | Return list vs DI sink vs both? | User asked for recommendation → Agent recommendation (adopted): return IReadOnlyList<PerkUnlockEvent> from engine methods; callers (grant hook, future HTTP in NEO-48) may ignore for now. NEO-49 can add an optional IPerkUnlockEventSink without changing persistence. |
| Level-up hook site | Inside SkillProgressionGrantOperations vs separate service? |
User: run inside SkillProgressionGrantOperations when levelUps is non-empty (same path as XP write). Implementation: call a small PerkUnlockEngine instance method from there (logic stays testable in Game/Mastery/, not duplicated in HTTP). |
Tier 1 vs tier 2 (plain language)
Prototype salvage mastery (prototype_salvage_mastery.json):
| Tier | Skill level gate | What happens |
|---|---|---|
| 1 | 2 | Player chooses scrap_efficiency or bulk_haul. No perks yet — only records the branch. |
| 2 | 4 | One perk unlocks on the path they already chose (e.g. salvage_scrap_efficiency_1 if they picked scrap_efficiency at tier 1). No second choice between the two branches. |
So “mutually exclusive per tier” applies to tier 1’s pick; tier 2 is “follow your path,” not a new fork.
Catalog rule (engine): A tier requires an explicit branch pick when every branch in that tier has an empty perkIds array. A tier auto-unlocks from path when branches have perks and the player already has a branchId pick on the highest prior tier that required a pick (for prototype: tier 1 → tier 2).
Goal, scope, and out-of-scope
Goal: Server-authoritative PerkState (unlocked perkId set + branchId per tierIndex per track) with unlock evaluation driven by skill level from E2.M2. When level crosses a tier gate, eligible tiers become selectable or auto-unlock per rules above. Re-evaluate on skill level-up after XP grants. Emit internal PerkUnlockEvent when perks unlock.
In scope (from Linear):
IPlayerPerkStateStore— in-memory + Postgres + migration (NEO-38 pattern).- Unlock engine: catalog + skill level;
PerkUnlockEventon unlock. - Mutually exclusive branch pick where applicable; stable
reasonCodeon invalid picks. - Level-up path after
SkillProgressionGrantOperationswhenlevelUpsnon-empty. - Unit/integration tests (AAA): eligibility, branch select, deny paths, level-up auto-unlock.
Out of scope (from Linear):
- HTTP read/write (NEO-48).
- Gameplay
effectKindapplication; respec; gig perks.
Acceptance criteria checklist
IPlayerPerkStateStorewith in-memory + Postgres path (migration) mirroring skill progression pattern.- Unlock engine reads catalog + skill level; emits internal
PerkUnlockEventwhen perks unlock. - Branch pick per tier is mutually exclusive where a tier requires a pick; stable deny
reasonCodeon invalid picks. - Level-up path (after
SkillProgressionGrantOperations) triggers re-evaluation for affected skill. - Unit/integration tests (AAA): eligibility, branch select, deny paths.
Technical approach
-
Domain model (
Game/Mastery/):PerkStateSnapshot—branchPicks:skillId→tierIndex→branchId;unlockedPerkIds: set ofperkId.PerkUnlockEvent—playerId,perkId,skillId,tierIndex,branchId?,source(branch_pick|level_up).PerkUnlockEngine— pure orchestration:TrySelectBranch(...),ReevaluateAfterLevelUp(...), both returnPerkBranchSelectOutcome/PerkReevaluationOutcomewithsuccess,reasonCode?,events, updated snapshot.
-
Eligibility (skill level): Resolve level via
IPlayerSkillProgressionStore.GetXpTotals+ISkillLevelCurve.LevelFromTotalXp(same as GET skill progression). Tier T is reachable whenlevel >= tier.RequiredLevel. -
TrySelectBranch(playerId, skillId, tierIndex, branchId):- Deny:
unknown_track,unknown_tier,unknown_branch,level_too_low,branch_already_chosen,tier_branch_not_selectable(tier auto-unlocks from path — all branches have perks). - Accept: persist pick; unlock any
perkIdson the chosen branch for that tier (tier 1: none); emit events. - Player write gate: store returns false when player missing from position/progression pattern →
player_not_found(align with skill XP store).
- Deny:
-
ReevaluateAfterLevelUp(playerId, skillId, newLevel):- For each tier on track where
requiredLevel <= newLeveland tier is path-auto (has perks, prior pick exists): unlock perks for branch matching prior pick’sbranchId; skip already-unlocked ids; persist + events. - Idempotent: re-running does not duplicate perks or events in state.
- For each tier on track where
-
Persistence (
IPlayerPerkStateStore):GetSnapshot(string playerId)bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)— fails if pick exists or player missing.bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)— additive, idempotent per perk.- Postgres:
V004__player_perk_state.sql—player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id)PK(player_id, skill_id, tier_index);player_unlocked_perk (player_id, perk_id)PK(player_id, perk_id); FK toplayer_position. Bootstrap:PostgresPerkStateBootstrap(mirrorPostgresSkillProgressionBootstrap).
-
DI:
AddPerkStateStore(IServiceCollection, IConfiguration)— Postgres whenConnectionStrings:NeonSprawlset, else in-memory. RegisterPerkUnlockEngineas singleton (depends on store,IMasteryCatalogRegistry,IPlayerSkillProgressionStore,ISkillLevelCurve). -
Level-up hook: In
SkillProgressionGrantOperations.TryApplyGrant, after successful XP apply, foreach entry inlevelUpscallperkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel). InjectPerkUnlockEnginevia new overload or optional parameter onTryApplyGrant(prefer overload used by HTTP + interact to avoid static service locator). Events returned but not yet exposed on HTTP response (NEO-48). -
Reason codes (stable,
snake_case): Document in code constants onPerkUnlockReasonCodes(or engine type); mirror in tests.Code When unknown_trackNo mastery track for skillIdunknown_tiertierIndexnot on trackunknown_branchbranchIdnot in tierlevel_too_lowSkill level below requiredLevelbranch_already_chosenPick already stored for (skillId, tierIndex)tier_branch_not_selectableTier auto-unlocks from path (no manual pick) player_not_foundStore cannot write player -
Tests: Use repo
content/mastery+ level curve (level 2@ 100 XP,level 4@ 450 XP). Manipulate XP viaIPlayerSkillProgressionStoreorTryApplyGrantin integration tests.
Files to add
| Path | Purpose |
|---|---|
server/db/migrations/V004__player_perk_state.sql |
Flyway DDL for branch picks + unlocked perks. |
server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs |
In-memory read model for one player. |
server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs |
Internal unlock event DTO. |
server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs |
Stable deny reason constants. |
server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs |
Result of TrySelectBranch. |
server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs |
Result of ReevaluateAfterLevelUp. |
server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs |
Persistence abstraction. |
server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs |
In-memory store (dev player seed pattern from skill store). |
server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs |
Postgres implementation. |
server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs |
One-time DDL apply for V004. |
server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs |
AddPerkStateStore + register PerkUnlockEngine. |
server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs |
Catalog + level + store orchestration. |
server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs |
Unit tests: tier-1 pick, denies, level-4 auto-unlock, idempotent reevaluate. |
server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs |
Postgres: branch pick + unlock survive new factory. |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs |
Call PerkUnlockEngine.ReevaluateAfterLevelUp for each levelUps entry on successful grant (user kickoff). |
server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs |
Pass PerkUnlockEngine into TryApplyGrant from POST handler. |
server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs |
Pass engine into gather XP grant path (NEO-41). |
server/NeonSprawl.Server/Program.cs |
AddPerkStateStore(builder.Configuration). |
server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs |
Replace IPlayerPerkStateStore with in-memory implementation for isolated tests. |
server/NeonSprawl.Server/NeonSprawl.Server.csproj |
Copy V004 SQL to output (if not already covered by existing migration copy pattern for V003). |
Tests
| Test file | Coverage |
|---|---|
PerkUnlockEngineTests.cs |
AAA: At salvage level 2, TrySelectBranch tier 1 scrap_efficiency succeeds; other branch’s tier-2 perk not unlocked. Deny: level_too_low at level 1; branch_already_chosen on second pick; unknown_branch; tier_branch_not_selectable on tier 2 pick. Level-up: set XP to 450+, call ReevaluateAfterLevelUp → salvage_scrap_efficiency_1 unlocked; bulk_haul perk not unlocked. Idempotent: second reevaluate adds no duplicate perks. |
PerkStatePersistenceIntegrationTests.cs |
RequirePostgres: pick branch + unlock via engine, new PostgresWebApplicationFactory, GetSnapshot shows pick + perk. |
SkillProgressionGrantOperations (via existing grant API tests or one new test) |
Grant salvage XP crossing level 4 with tier-1 pick already stored → perk unlocked without HTTP perk endpoint (hook integration smoke). |
All new [Fact] methods use // Arrange / // Act / // Assert per csharp-style.
Open questions / risks
- Tier rule generalization: “Empty
perkIdson all branches ⇒ pick tier” must stay aligned with future multi-tier catalogs; add loader comment cross-ref to engine. - Grant hook + Postgres transactions: Skill XP write and perk unlock are separate stores today (same as hotbar vs position); acceptable for prototype; document if cross-store atomicity becomes a requirement.
- NEO-48 DTO shape: Internal
PerkStateSnapshotshould map cleanly to versioned HTTP JSON without renaming fields.
None blocking after kickoff clarifications.
Decisions
| Decision | Rationale |
|---|---|
Tier 2 = auto-path from tier-1 branchId |
E2M3 backlog “chosen branch path”; user needed plain-language explanation — adopted agent recommendation. |
| Normalized Postgres tables (V004) | User asked for recommendation; matches NEO-38 and query-friendly perk checks for future gates. |
| Return event list from engine | User asked for recommendation; minimal surface for NEO-47; NEO-49 can add sink. |
Hook inside SkillProgressionGrantOperations |
Explicit user choice; delegate to PerkUnlockEngine for testability. |
Code under Game/Mastery/ |
NEO-46 boundary; cohesive with catalog registry. |