# T009 — Online Reward Delivery Verification

Goal: prove that, in a real ONLINE session, completing a reward-worthy action
GRANTS the reward (diamonds via the shared diamond economy) AND SENDS the reward
to the connected client (`session.send`). The headless lifetime-simulator
(`tests/e2e/metrics.test.ts`) reports `reward cadence -- total=0` because it
drives the offline `GameEngine`/`LoopManager` path, never the online
handler/hook path where rewards are actually granted and delivered. This task
closes that confidence gap with a focused integration test.

## Bottom line

REWARDS DO REACH THE CLIENT ON THE ONLINE PATH. No product-code gap found. The
new test exercises all three live reward systems end-to-end and the FU-A diamond
clamp coexists cleanly. No escalation required.

## What was exercised (real online code, not the simulator)

New test: `server/tests/services/retention/rewardDelivery.online.integration.test.ts`

Storage is forced in-memory (`disableDatabaseStorage()`), which is the same
fallback the production handlers use when MySQL is unavailable — so the test runs
the real handler/hook code without a live DB.

### 1. Daily quests — via the REAL command handler
- Path: `generateDailyQuests` -> `updateQuestProgress` (the fn the integration
  hooks call) -> drive to completion -> `handleClaimQuestReward(...)`
  (`src/handlers/retention.ts`, the actual client command handler).
- Asserts:
  - (a) `getDiamondBalance(player)` increases by exactly `quest.diamondReward`
    (was 0 before claim — completion alone does NOT pay out; claim does, via
    `awardDiamonds` in `claimQuestRewardMemory`, dailyQuests.ts:1298).
  - (b) client receives a `questRewardClaimed` message with `success: true` and
    `reward.diamonds === quest.diamondReward`, plus a refreshed player object
    (`sendPlayerObject`) and a re-emitted `dailyQuestsStatus`.
  - Double-claim is rejected (no double-pay; balance unchanged; `success: false`).

### 2. Achievements — via the REAL hook `onChildBorn`
- Path: `onChildBorn(session)` (`src/services/retention/integration.ts:268`) ->
  `checkAchievementsAsync` -> sync `checkAchievements` -> `checkAndUnlock` ->
  `unlockAchievement` (awards diamonds, achievements.ts:556) ->
  `sendAchievementsUnlocked` -> `session.send({ type: 'achievementUnlocked' })`.
- Asserts:
  - (a) diamonds awarded (`getDiamondBalance > 0`, equal to `have_child`'s reward).
  - (b) client receives `achievementUnlocked` with `achievement.key === 'have_child'`
    and `reward > 0`.
- Note: the async DB persist inside `checkAchievementsAsync` throws "Database pool
  not initialized" in the test env, but it is caught/logged and does NOT block the
  grant or the client send — the synchronous path already did both. This is a
  useful robustness signal: rewards still reach the client even if the DB write
  fails.

### 3. Life goals — via the REAL hook `updateLifeGoals`
- Path: `updateLifeGoals(session)` (`integration.ts:616`) -> `evaluateGoals`
  (awards diamonds on completion, lifeGoals.ts:419) -> `sendLifeGoalsUpdate` ->
  `session.send({ type: 'lifeGoalsUpdate' })`.
- Drives "Raise a Family" (`raise_two_children`) to its target of 2 children.
- Asserts:
  - (a) diamonds granted to `player.c.diamonds` (equal to the goal's reward).
  - (b) client receives `lifeGoalsUpdate` whose `justCompleted` array contains
    `raise_two_children` with `reward > 0`; the hook's return value agrees.

### FU-A diamond clamp coexistence
- Earn a real reward (via `onChildBorn`), then:
  - an affordable `spendDiamonds` deducts cleanly;
  - an overspend is refused and leaves the balance untouched (never negative);
  - draining the exact balance clamps to 0, never below.
- Confirms the clamp in `diamondEconomy.writeBalance` (`Math.max(0, amount)`,
  diamondEconomy.ts:97) holds alongside the reward path.

## Verify results (from repo root)
- `cd server && npx tsc --noEmit` -> exit 0.
- `cd server && npx vitest run` -> 141 files / **1678 tests passing** (baseline
  1673 + 5 new). New file passes in isolation (5/5).
- Retention + handlers subset: 23 files / 225 tests pass.

## Note on the simulator's reward cadence = 0
This is expected and is NOT a bug: `tests/e2e/metrics.test.ts` runs the offline
`GameEngine` path, which does not invoke the online retention hooks/handlers.
Reward delivery lives on the online `PlayerSession` path, which this test now
covers directly. (No in-app spot-check performed; the integration test is the
primary deliverable and fully covers the grant + client-send contract.)
