13 KiB
NEO-107 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-107 |
| Title | E5M3-09: EncounterCompleteEvent + E7.M2 quest-credit hook stub |
| Linear | https://linear.app/neon-sprawl/issue/NEO-107/e5m3-09-encountercompleteevent-e7m2-quest-credit-hook-stub |
| Module | E5.M3 — EncounterAndRewardTables · Epic 5 Slice 3 · backlog E5M3-09 |
| Branch | NEO-107-encounter-complete-event-e7m2-hook |
| Precursor | NEO-105 — EncounterCompleteEvent result payload + TryCompleteAndGrant (landed on main) · NEO-106 — combat wiring invokes completion (landed on main) |
| Pattern | NEO-104 — split in-memory stores + per-key locks; NEO-105 — event type + idempotency key already defined |
| Blocks | NEO-110 — client encounter HUD (indirect; needs event record for downstream parity) |
| Client counterpart | None this story — server-only event record + hook stub; player-visible HUD is NEO-110 via NEO-108 |
Kickoff clarifications
No blocking clarifications needed. NEO-105 kickoff adopted the split: NEO-105 returns EncounterCompleteEvent in EncounterCompletionResult; NEO-107 persists the record and expands the E7.M2 hook. E5M3 backlog defaults fix in-memory storage, once-per-player+encounter idempotency, and comment-only E7.M2 behavior (no QuestRewardBundle router). NEO-106 wires all completion through TryCompleteAndGrant — a single record site preserves atomicity with TryMarkCompleted.
| Topic | Question | Agent recommendation | Answer |
|---|---|---|---|
| Event store shape | Dedicated store vs extend IEncounterCompletionStore? |
Dedicated IEncounterCompleteEventStore — completion flag is boolean+timestamp; event carries grant summary + idempotency key (NEO-104 split-store precedent). |
Adopted (backlog + NEO-105) |
| Record site | Inside TryCompleteAndGrant vs caller after success? |
Inside TryCompleteAndGrant after TryMarkCompleted succeeds — same completion commit boundary; NEO-106 / cast path need no duplicate orchestration. |
Adopted (NEO-105 deferral note) |
| Storage backend | In-memory vs Postgres? | In-memory only — E5M3 kickoff default (NEO-104). | Adopted |
| E7.M2 runtime | Stub router call vs comments only? | Comments only — document QuestRewardBundle / RewardDeliveryEvent consumer contract; no quest state machine (E5M3-09). |
Adopted |
| HTTP / Godot | Expose event on GET or client? | Defer — NEO-108 projects grant summary from stores; this story records only. | Adopted (backlog out-of-scope) |
Goal, scope, and out-of-scope
Goal: Persist EncounterCompleteEvent exactly once per player+encounter when completion grants commit, and expand the E7.M2 quest-credit hook stub documenting the future QuestRewardBundle consumer contract.
In scope (from Linear + E5M3-09):
IEncounterCompleteEventStore+InMemoryEncounterCompleteEventStore— idempotentTryRecord(EncounterCompleteEvent)keyed by(playerId, encounterId)/IdempotencyKey;TryGetfor tests and NEO-108 read path.- Wire store into
EncounterCompletionOperations.TryCompleteAndGrant— record on success after completion mark; replay / deny paths do not record. - Expand
// TODO(E7.M2)hook in completion path withreward_deliveryvocabulary and pointer to E7_M2_RewardAndUnlockRouterQuestRewardBundle/RewardDeliveryEventconsumer contract (prototype: quest credit beyondcontract_handoff_tokenitem loot). - Pass
IEncounterCompleteEventStorethroughEncounterCombatWiring→AbilityCastApiDI chain. - Unit tests (AAA): first completion records event once; second completion does not duplicate record; deny paths leave store empty.
server/README.mdencounter-complete-event section; E5.M3 module snapshot + E7.M2 implementation anchor.
Out of scope (from Linear + backlog):
- Production
QuestRewardBundleapply, quest state machine,RewardDeliveryEventtype implementation (E7.M2 full router). GET /game/players/{id}/encounter-progressprojection (NEO-108).- Telemetry
encounter_completeingest (NEO-109). - Godot HUD — client counterpart NEO-110.
- Postgres persistence, Bruno,
docs/manual-qa/NEO-107.md.
Acceptance criteria checklist
- Completion path records
EncounterCompleteEventexactly once per player+encounter. - Hook documents E7.M2
QuestRewardBundleconsumer contract (comments + module cross-link). - No runtime behavior beyond event record + comments.
Implementation reconciliation (shipped)
- Store:
IEncounterCompleteEventStore+InMemoryEncounterCompleteEventStore— idempotentTryRecord/TryGetkeyed by(playerId, encounterId). - Wiring:
EncounterCompletionOperations.TryCompleteAndGrantrecords event after successful completion mark;EncounterCombatWiring+AbilityCastApipass store through DI. - Hook: Expanded E7.M2
reward_deliveryTODO comments; E7_M2 implementation anchor. - Tests:
InMemoryEncounterCompleteEventStoreTests; extendedEncounterCompletionOperationsTests,EncounterCombatWiringTests,EncounterDefinitionRegistryTests. - Docs:
server/README.md; E5.M3 module snapshot + alignment register + backlog + module dependency register updated.
Technical approach
-
IEncounterCompleteEventStorebool TryRecord(EncounterCompleteEvent completeEvent)—trueon first insert for normalized(playerId, encounterId);falseif already recorded (idempotent no-op).bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent)— read back stored event for tests / future HTTP.
-
InMemoryEncounterCompleteEventStore- Key:
EncounterProgressIds.MakeProgressKey(playerId, encounterId)(same as progress/completion stores). - Value: full
EncounterCompleteEventstruct (includingGrantedItemssnapshot at record time). - Thread-safe:
ConcurrentDictionary+ per-key locks (mirrorInMemoryEncounterCompletionStore).
- Key:
-
EncounterCompletionOperations.TryCompleteAndGrant-
Add parameter
IEncounterCompleteEventStore completeEventStore. -
After
TryMarkCompletedsucceeds andEncounterCompleteEventis built:- Call
completeEventStore.TryRecord(completeEvent). - If
TryRecordreturnsfalse(unexpected after fresh mark — invariant violation / race), do not rollback grants or completion flag; treat as idempotent success (event already present for this key). Document in README.
- Call
-
Replace terse TODO with expanded E7.M2 hook block:
// TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent // (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent. // Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2.
-
-
DI
- Register in
EncounterCatalogServiceCollectionExtensions.AddEncounterAndRewardCatalogs:services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();
- Register in
-
Call chain updates
EncounterCombatWiring.TryProcessCastOutcome— resolveIEncounterCompleteEventStorefrom deps; pass toTryCompleteAndGrant.AbilityCastApi— injectIEncounterCompleteEventStoreinto wiring call (alongside existing encounter/inventory deps).
-
Docs
server/README.md— new subsection under encounter completion: event store, idempotency, E7.M2 deferral.E5_M3_EncounterAndRewardTables.md— implementation snapshot for E5M3-09.E7_M2_RewardAndUnlockRouter.md— implementation anchor:EncounterCompleteEventproducer + future consumer hook.- Backlog / alignment register / module dependency register — note NEO-107 when landed.
Prototype completion → event flow (frozen)
| Step | Expected |
|---|---|
First TryCompleteAndGrant success |
Completion store marked; TryRecord stores event with scrap ×10 + token ×1 summary |
Second TryCompleteAndGrant |
Deny already_completed before record; event store unchanged |
Inventory deny / not_ready |
No completion mark; event store empty for that encounter |
| Cast path (NEO-106) | Third defeat → same record via TryCompleteAndGrant |
Files to add
| Path | Purpose |
|---|---|
server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs |
Injectable event persistence contract. |
server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs |
Thread-safe in-memory implementation (NEO-107). |
server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs |
AAA store idempotency + TryGet tests. |
docs/plans/NEO-107-implementation-plan.md |
This plan. |
Files to modify
| Path | Rationale |
|---|---|
server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs |
Accept event store; TryRecord on success; expand E7.M2 hook comments. |
server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs |
Update XML — persistence landed on NEO-107. |
server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs |
Pass IEncounterCompleteEventStore into TryCompleteAndGrant. |
server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs |
Inject event store for wiring call. |
server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs |
Register IEncounterCompleteEventStore singleton. |
server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs |
Assert event recorded once; replay / deny paths leave store empty; update ResolveDependencies. |
server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs |
Assert event store populated after third-defeat wiring completion. |
server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs |
Host DI resolves IEncounterCompleteEventStore (if not already covered). |
server/README.md |
Document event store + E7.M2 hook stub; remove NEO-107 deferral notes. |
docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md |
Implementation snapshot — E5M3-09 event persistence. |
docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md |
Implementation anchor — EncounterCompleteEvent producer hook. |
docs/decomposition/modules/documentation_and_implementation_alignment.md |
E5.M3 row — note NEO-107 event record when landed. |
docs/decomposition/modules/module_dependency_register.md |
E5.M3 note — NEO-107 event store when landed. |
docs/plans/E5M3-prototype-backlog.md |
Mark E5M3-09 AC when complete. |
Tests
| Test file | What it covers |
|---|---|
InMemoryEncounterCompleteEventStoreTests.cs |
Arrange: prototype EncounterCompleteEvent. Act/Assert: first TryRecord → true + TryGet round-trip; second TryRecord same key → false; empty key / invalid ids fail closed. |
EncounterCompletionOperationsTests.cs |
Extend existing grant-once test: assert TryGet returns event matching CompleteEvent; already_completed / not_ready / inventory_full paths assert store still empty (or unchanged count). |
EncounterCombatWiringTests.cs |
Extend third-defeat completion test: assert event store has record after wiring success. |
EncounterDefinitionRegistryTests.cs |
Assert: host startup resolves IEncounterCompleteEventStore as InMemoryEncounterCompleteEventStore. |
Open questions / risks
| Question / risk | Agent recommendation | Status |
|---|---|---|
| Record after mark ordering | Record only after TryMarkCompleted succeeds — event mirrors committed completion. |
adopted |
| TryRecord false after fresh mark | Treat as idempotent (event already there); no grant rollback — same key invariant as completion store. | adopted |
| NEO-108 grant summary | HTTP read may use event store GrantedItems or completion + inventory delta — defer projection design to NEO-108; store exposes TryGet. |
deferred |
| Postgres follow-on | Defer — session persistence migration is a future issue. | deferred |