# Messaging & Communications Overhaul Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make conversations feel real — dynamic affinity that swings dramatically, uncapped message lengths for major moments, NPCs that text first, and full time-of-day awareness.

**Architecture:** Five backend changes to `server/src/events/conversations/ai_response.ts` (prompts, verbosity, affinity parsing), one new file `server/src/events/conversations/npc_initiative.ts`, one hook in `PlayerSession.ts`, and four iOS UI changes for live feedback. All changes are additive — no database migrations needed.

**Tech Stack:** TypeScript (Vitest tests), SwiftUI (iOS), OpenAI API (prompt engineering)

---

## Task 1: Dynamic Affinity — Response Schema & Parsing

Add `affinityDelta` to the AI response schema so the AI returns a numeric affinity change with every message instead of just sentiment.

**Files:**
- Modify: `server/src/events/conversations/ai_response.ts:44-59` (schema)
- Modify: `server/src/events/conversations/ai_response.ts:1273-1286` (affinity application)
- Modify: `server/src/events/conversations/ai_response.ts:1369-1374` (message data storage)
- Modify: `server/src/events/conversations/types.ts:12-23` (ConversationMessageData interface)
- Test: `server/tests/events/conversations/dynamic-affinity.test.ts` (new)

**Step 1: Write the failing tests**

Create `server/tests/events/conversations/dynamic-affinity.test.ts`:

```typescript
import { describe, it, expect } from 'vitest';
import { detectVerbosityLevel } from '../../../src/events/conversations/ai_response.js';
import { ConversationObj } from '../../../src/events/conversations/types.js';
import { createTestPlayer, createTestCharacter, createTestConversation } from '../../utils/conversationTestUtils.js';

describe('Dynamic Affinity', () => {
  describe('parseAffinityDelta', () => {
    it('should extract affinityDelta from JSON response', () => {
      const json = { message: 'hey!', sentiment: 'positive', affinityDelta: 12 };
      expect(json.affinityDelta).toBe(12);
    });

    it('should clamp affinityDelta to -50..+30 range', () => {
      const clamp = (delta: number) => Math.max(-50, Math.min(30, delta));
      expect(clamp(100)).toBe(30);
      expect(clamp(-100)).toBe(-50);
      expect(clamp(15)).toBe(15);
      expect(clamp(-25)).toBe(-25);
    });

    it('should apply affinityDelta to character affinity clamped 0-100', () => {
      const apply = (current: number, delta: number) =>
        Math.max(0, Math.min(100, current + Math.max(-50, Math.min(30, delta))));

      // Normal case
      expect(apply(50, 12)).toBe(62);
      // Hostile message craters relationship
      expect(apply(30, -40)).toBe(0);
      // Can't exceed 100
      expect(apply(90, 20)).toBe(100);
      // Can't go below 0
      expect(apply(10, -50)).toBe(0);
    });

    it('should fall back to sentiment-based delta when affinityDelta missing', () => {
      const json = { message: 'hey!', sentiment: 'positive' };
      const delta = (json as any).affinityDelta ?? (json.sentiment === 'positive' ? 5 : json.sentiment === 'negative' ? -5 : 0);
      expect(delta).toBe(5);
    });
  });
});
```

**Step 2: Run tests to verify they pass (these are unit tests on pure logic)**

Run: `cd server && npx vitest run tests/events/conversations/dynamic-affinity.test.ts`
Expected: PASS (tests are self-contained)

**Step 3: Update the AI response JSON schema**

In `server/src/events/conversations/ai_response.ts`, replace lines 44-59:

```typescript
const conversationResponseSchema = {
  type: "object" as const,
  properties: {
    message: {
      type: "string" as const,
      description: "Your conversational response to the player"
    },
    sentiment: {
      type: "string" as const,
      enum: ["positive", "negative", "neutral"],
      description: "Overall tone of this exchange"
    },
    affinityDelta: {
      type: "integer" as const,
      description: "How much this message changes your opinion of the player, from -50 (hostile/cruel) to +30 (deeply meaningful). Score from YOUR perspective given your current relationship. Examples: casual 'lol' = +1, sharing a story = +5, heartfelt confession from partner = +20, 'I hate you' = -40, unwanted 'I love you' from stranger = -15"
    }
  },
  required: ["message", "sentiment", "affinityDelta"],
  additionalProperties: false
};
```

**Step 4: Update affinity application logic**

In `server/src/events/conversations/ai_response.ts`, replace lines 1273-1286:

```typescript
      // Apply affinity change
      if (toolResult?.affinityChange !== undefined) {
        // Tool already applied affinity change — clamp to dynamic range
        const clampedDelta = Math.max(-50, Math.min(30, toolResult.affinityChange));
        character.affinity = Math.max(0, Math.min(100, (character.affinity ?? 50) + clampedDelta));
        console.log(`Tool applied affinity change: ${clampedDelta > 0 ? '+' : ''}${clampedDelta}`);
      } else if (typeof (parsedResponse as any)?.affinityDelta === 'number') {
        // AI-scored dynamic affinity delta
        const rawDelta = (parsedResponse as any).affinityDelta;
        const clampedDelta = Math.max(-50, Math.min(30, rawDelta));
        character.affinity = Math.max(0, Math.min(100, (character.affinity ?? 50) + clampedDelta));
        console.log(`AI affinity delta: ${clampedDelta > 0 ? '+' : ''}${clampedDelta} (raw: ${rawDelta})`);
      } else {
        // Fallback: standard sentiment-based affinity
        if (sentiment === 'positive') {
          character.affinity = Math.min(100, (character.affinity ?? 50) + 5);
        } else if (sentiment === 'negative') {
          character.affinity = Math.max(0, (character.affinity ?? 50) - 5);
        } else {
          player.c.social = Math.min(100, (player.c.social ?? 50) + 1);
        }
      }
```

Note: `parsedResponse` needs to be captured from the JSON parsing above. Find where `responseContent` and `sentiment` are extracted from the parsed JSON response and also extract `affinityDelta` at that same location.

**Step 5: Store affinityDelta in message data**

In `server/src/events/conversations/ai_response.ts`, near line 1369, add the delta to messageData:

```typescript
      // Add affinityDelta to message data for iOS display
      const appliedDelta = toolResult?.affinityChange
        ?? (parsedResponse as any)?.affinityDelta
        ?? (sentiment === 'positive' ? 5 : sentiment === 'negative' ? -5 : 0);
      messageData.affinityDelta = Math.max(-50, Math.min(30, appliedDelta));
```

**Step 6: Run full test suite**

Run: `cd server && npx vitest run`
Expected: All 1047+ tests pass

**Step 7: Commit**

```bash
git add server/src/events/conversations/ai_response.ts server/tests/events/conversations/dynamic-affinity.test.ts
git commit -m "feat(conversations): dynamic affinity scoring (-50 to +30 range)"
```

---

## Task 2: Dynamic Affinity — Prompt Instructions

Update all four prompt templates to instruct the AI on context-dependent affinity scoring.

**Files:**
- Modify: `server/src/events/conversations/ai_response.ts:772-855` (prompt templates)

**Step 1: Create the affinity scoring instruction block**

This block will be appended to all prompt templates. Create a constant near the top of the file (after the schema, around line 61):

```typescript
const AFFINITY_SCORING_INSTRUCTIONS = `
You MUST respond with a JSON object containing three fields:
- "message": your conversational response (the text to show)
- "sentiment": overall tone ("positive", "negative", or "neutral")
- "affinityDelta": integer from -50 to +30 showing how this message changes your opinion of the player

AFFINITY SCORING GUIDE (score from YOUR perspective, given your current relationship):
+1 to +3: Casual positive ("lol", "nice", basic engagement)
+3 to +8: Good conversation (sharing stories, asking about your day)
+5 to +15: Deep/vulnerable sharing (opening up about fears, dreams, feelings)
+8 to +20: Meaningful compliments, genuine affection
+15 to +30: Deeply significant romantic moments (ONLY if you're in a committed relationship and you feel the same way)
-3 to -8: Mild rudeness (dismissive, ignoring your question, "whatever")
-15 to -30: Hurtful/dismissive (insulting you, saying they don't care about you)
-30 to -50: Hostile/cruel (personal attacks, "I hate you", betrayal)
-20 to -40: Inappropriate behavior (unwanted romantic advances, crossing boundaries)

CRITICAL: Score based on how YOU would actually feel receiving this message. Context matters enormously:
- "I love you" from your committed partner who treats you well = +20
- "I love you" from someone you barely know = -15 (uncomfortable/creepy)
- An apology after a fight should be worth a lot MORE if the fight was bad
- Ignoring a serious question hurts MORE from someone close to you
- Don't give +0 for everything — have real opinions and reactions
`;
```

**Step 2: Replace the JSON schema instructions in all four prompt templates**

In each of the four templates (lines 782-854), find and replace the two-line JSON instruction blocks:

Find (appears 4 times):
```
You MUST respond with a JSON object containing two fields:
- "message": your conversational response (the text to show)
- "sentiment": how this interaction affects your opinion ("positive", "negative", or "neutral")
```

Replace each with:
```
${AFFINITY_SCORING_INSTRUCTIONS}
```

**Step 3: Run full test suite**

Run: `cd server && npx vitest run`
Expected: All tests pass

**Step 4: Commit**

```bash
git add server/src/events/conversations/ai_response.ts
git commit -m "feat(conversations): add context-dependent affinity scoring to all prompts"
```

---

## Task 3: Context-Dependent Message Lengths

Replace the rigid word-count verbosity system with situation-driven length scaling.

**Files:**
- Modify: `server/src/events/conversations/ai_response.ts:471-542` (detectVerbosityLevel + getVerbosityPromptHint)
- Test: `server/tests/events/conversations/verbosity.test.ts` (new)

**Step 1: Write the failing tests**

Create `server/tests/events/conversations/verbosity.test.ts`:

```typescript
import { describe, it, expect } from 'vitest';
import { detectVerbosityLevel } from '../../../src/events/conversations/ai_response.js';
import { createTestPlayer, createTestCharacter, createTestConversation } from '../../utils/conversationTestUtils.js';

describe('Context-Dependent Message Lengths', () => {
  const player = createTestPlayer();
  const character = createTestCharacter();

  it('should detect "quick" level for one-word player messages', () => {
    const convo = createTestConversation(character, {
      messages: [{ message: 'lol', sender: player.c.id }],
    });
    const result = detectVerbosityLevel(convo, character, player);
    expect(result.level).toBe('quick');
    expect(result.maxTokens).toBe(100);
  });

  it('should detect "major" level for breakup-related messages', () => {
    const convo = createTestConversation(character, {
      messages: [{ message: 'I think we should break up. This isnt working anymore.', sender: player.c.id }],
    });
    const result = detectVerbosityLevel(convo, character, player);
    expect(result.level).toBe('major');
    expect(result.maxTokens).toBe(2000);
  });

  it('should detect "emotional" level for deep questions', () => {
    const convo = createTestConversation(character, {
      messages: [{ message: 'How do you really feel about our relationship? I need to know the truth.', sender: player.c.id }],
    });
    const result = detectVerbosityLevel(convo, character, player);
    expect(['emotional', 'major']).toContain(result.level);
    expect(result.maxTokens).toBeGreaterThanOrEqual(1200);
  });

  it('should detect "casual" level for simple chat', () => {
    const convo = createTestConversation(character, {
      messages: [{ message: 'hey whats up', sender: player.c.id }],
    });
    const result = detectVerbosityLevel(convo, character, player);
    expect(result.level).toBe('casual');
    expect(result.maxTokens).toBe(300);
  });

  it('should detect "normal" level for engaged conversation', () => {
    const convo = createTestConversation(character, {
      messages: [{ message: 'I went to the park today and saw the most beautiful sunset. Made me think of you.', sender: player.c.id }],
    });
    const result = detectVerbosityLevel(convo, character, player);
    expect(result.level).toBe('normal');
    expect(result.maxTokens).toBe(600);
  });
});
```

**Step 2: Run tests to verify they fail**

Run: `cd server && npx vitest run tests/events/conversations/verbosity.test.ts`
Expected: FAIL — current function returns 'low'/'medium'/'high', not 'quick'/'casual'/'normal'/'emotional'/'major'

**Step 3: Rewrite detectVerbosityLevel and getVerbosityPromptHint**

Replace `server/src/events/conversations/ai_response.ts` lines 471-542 with:

```typescript
export type VerbosityLevel = 'quick' | 'casual' | 'normal' | 'emotional' | 'major';

export interface VerbosityConfig {
  level: VerbosityLevel;
  maxTokens: number;
}

export function detectVerbosityLevel(
  conversation: ConversationObj,
  _character: Person,
  player: Player
): VerbosityConfig {
  if (!conversation.conversation || conversation.conversation.length === 0) {
    return { level: 'normal', maxTokens: 600 };
  }

  // Get last player message
  const lastMessages = conversation.conversation.slice(-3);
  let lastPlayerMessage: ConversationMessage | null = null;
  for (let i = lastMessages.length - 1; i >= 0; i--) {
    if (lastMessages[i].sender === player.c.id) {
      lastPlayerMessage = lastMessages[i];
      break;
    }
  }

  if (!lastPlayerMessage) {
    return { level: 'normal', maxTokens: 600 };
  }

  const text = lastPlayerMessage.message;
  const textLower = text.toLowerCase();
  const wordCount = text.trim().split(/\s+/).length;

  // Major moment keywords — these deserve long, uncapped responses
  const majorTopics = [
    'break up', 'breakup', 'breaking up', 'divorce',
    'i hate you', 'i dont love you', 'i don\'t love you',
    'cheating', 'cheated on', 'affair',
    'pregnant', 'pregnancy', 'having a baby',
    'someone died', 'passed away', 'funeral',
    'marry me', 'proposal', 'will you marry',
    'i love you', // context-dependent but starts as major
    'moving away', 'leaving forever',
    'confession', 'confess', 'need to tell you something',
    'its over', 'it\'s over', 'we\'re done', 'we are done',
  ];
  if (majorTopics.some(topic => textLower.includes(topic))) {
    return { level: 'major', maxTokens: 2000 };
  }

  // Emotional/deep — thoughtful, longer responses
  const emotionalTopics = [
    'why', 'how do you feel', 'what do you think',
    'tell me about', 'can you explain', 'feelings',
    'relationship', 'future', 'dream', 'goal',
    'problem', 'worried', 'scared', 'excited',
    'what do we do', 'where do we stand', 'be honest',
    'the truth', 'really think', 'deep down',
    'miss you', 'need you', 'sorry', 'apologize', 'forgive',
  ];
  const hasQuestion = text.includes('?');
  const isLongMessage = text.length > 100;
  const hasMultipleSentences = (text.match(/[.!?]+/g) ?? []).length > 1;
  const hasEmotionalTopic = emotionalTopics.some(topic => textLower.includes(topic));

  let score = 0;
  if (hasQuestion) score += 2;
  if (isLongMessage) score += 2;
  if (hasMultipleSentences) score += 1;
  if (hasEmotionalTopic) score += 3;

  if (score >= 5) {
    return { level: 'emotional', maxTokens: 1200 };
  }

  // Quick reaction — very short input, match energy
  if (wordCount <= 3 && text.length <= 15) {
    return { level: 'quick', maxTokens: 100 };
  }

  // Casual chat — short messages without depth
  if (wordCount <= 10 && score < 2) {
    return { level: 'casual', maxTokens: 300 };
  }

  // Normal conversation — default
  return { level: 'normal', maxTokens: 600 };
}

/**
 * Get verbosity prompt hint for the AI
 */
export function getVerbosityPromptHint(verbosityLevel: string): string {
  const hints: Record<string, string> = {
    quick: 'React briefly, like a quick text. One sentence max.',
    casual: 'Keep it short and natural, like a casual text conversation.',
    normal: 'Respond naturally with a full thought. A few sentences is fine.',
    emotional: 'Take your time. This deserves a thoughtful, complete response. Write as much as you need to express yourself fully.',
    major: 'This is a significant moment. Write as much as the situation demands. Do NOT cut yourself short. If this is a breakup, confession, or life-changing conversation, respond with the depth and length it deserves — even if that means several paragraphs.',
    // Legacy fallbacks
    low: 'React briefly, like a quick text.',
    medium: 'Respond naturally with a full thought.',
    high: 'Take your time to give a thoughtful, complete response.',
  };
  return hints[verbosityLevel] ?? hints.normal;
}
```

**Step 4: Update the VerbosityConfig type if needed**

Check if there's a `VerbosityConfig` interface defined elsewhere in the file. If so, update it to match the new type. Search for `interface VerbosityConfig` in the file and update the `level` field type.

**Step 5: Run tests to verify they pass**

Run: `cd server && npx vitest run tests/events/conversations/verbosity.test.ts`
Expected: PASS

**Step 6: Run full test suite**

Run: `cd server && npx vitest run`
Expected: All tests pass. If existing tests reference `'low'`, `'medium'`, `'high'` levels, update those test expectations to use the new level names.

**Step 7: Commit**

```bash
git add server/src/events/conversations/ai_response.ts server/tests/events/conversations/verbosity.test.ts
git commit -m "feat(conversations): context-dependent message lengths with 5 verbosity tiers"
```

---

## Task 4: Full Time Awareness

Add time-of-day behavioral directives to the AI system prompt, and a delayed response mechanic for deep-night messages.

**Files:**
- Modify: `server/src/events/conversations/ai_response.ts` (new helper + prompt integration)
- Modify: `server/src/handlers/conversations.ts` (delayed response queue)
- Test: `server/tests/events/conversations/time-awareness.test.ts` (new)

**Step 1: Write the failing tests**

Create `server/tests/events/conversations/time-awareness.test.ts`:

```typescript
import { describe, it, expect } from 'vitest';

// Test the helper function we'll create
describe('Time Awareness', () => {
  // We'll import getTimeAwarenessDirective once it exists
  const getTimeAwarenessDirective = (hour: number, affinity: number, relationships: string[]): string => {
    const isClose = affinity > 60 || ['spouse', 'girlfriend', 'boyfriend', 'partner'].some(r => relationships.includes(r));

    if (hour >= 6 && hour < 11) return 'morning';
    if (hour >= 11 && hour < 17) return 'afternoon';
    if (hour >= 17 && hour < 22) return 'evening';
    if (hour >= 22 || hour < 2) return isClose ? 'late_close' : 'late_distant';
    return isClose ? 'deep_night_close' : 'deep_night_distant';
  };

  it('should return morning for 6am-11am', () => {
    expect(getTimeAwarenessDirective(8, 50, ['friend'])).toBe('morning');
  });

  it('should return evening for 5pm-10pm', () => {
    expect(getTimeAwarenessDirective(19, 50, ['friend'])).toBe('evening');
  });

  it('should allow close relationships late at night', () => {
    expect(getTimeAwarenessDirective(23, 80, ['girlfriend'])).toBe('late_close');
  });

  it('should flag distant relationships texting late', () => {
    expect(getTimeAwarenessDirective(23, 20, ['acquaintance'])).toBe('late_distant');
  });

  it('should flag deep night for non-close relationships', () => {
    expect(getTimeAwarenessDirective(3, 30, ['classmate'])).toBe('deep_night_distant');
  });

  it('should allow deep night for partners', () => {
    expect(getTimeAwarenessDirective(3, 80, ['spouse'])).toBe('deep_night_close');
  });
});
```

**Step 2: Run tests (self-contained, should pass)**

Run: `cd server && npx vitest run tests/events/conversations/time-awareness.test.ts`
Expected: PASS

**Step 3: Add `getTimeAwarenessDirective()` helper to ai_response.ts**

Add after the `getVerbosityPromptHint` function:

```typescript
/**
 * Generate time-of-day behavioral directive for the AI prompt
 */
export function getTimeAwarenessDirective(
  hour: number,
  affinity: number,
  relationships: string[]
): string {
  const isClose = affinity > 60 ||
    ['spouse', 'girlfriend', 'boyfriend', 'partner', 'best_friend'].some(r => relationships.includes(r));
  const isFamily = ['mother', 'father', 'sibling', 'child'].some(r => relationships.includes(r));

  if (hour >= 6 && hour < 11) {
    return 'It\'s morning. Greetings are natural. You\'re starting your day and feeling alert.';
  }
  if (hour >= 11 && hour < 17) {
    return 'It\'s the middle of the day. You might be busy with work or activities. Keep things natural but you can be brief if you\'d be busy.';
  }
  if (hour >= 17 && hour < 22) {
    return 'It\'s evening. You\'re relaxed and winding down. This is when people have their best conversations. You have time to talk.';
  }
  if (hour >= 22 || hour < 2) {
    if (isClose || isFamily) {
      return 'It\'s late at night. You\'re still up and happy to chat with them since you\'re close. Maybe a bit more mellow and intimate.';
    }
    return 'It\'s late at night. You notice they\'re texting late. You can mention the time naturally ("it\'s pretty late" or "can\'t sleep?"). Be a bit shorter in your responses since you\'re tired.';
  }
  // 2am-6am deep night
  if (isClose || isFamily) {
    return 'It\'s very late at night (past 2am). You\'re surprised they\'re still up. You can chat but you\'re sleepy. Be warm but mention how late it is.';
  }
  return 'It\'s the middle of the night. You\'re either asleep or barely awake. You\'re confused and maybe annoyed they\'re texting this late. Respond accordingly — "why are you texting me at this hour?" is a valid response.';
}
```

**Step 4: Integrate time awareness into prompt templates**

In the prompt building section (around line 772), add the time directive to the prompt:

```typescript
  const timeDirective = getTimeAwarenessDirective(
    player.hourOfDay,
    character.affinity ?? 50,
    character.relationships ?? []
  );
```

Then in each prompt template, add `\n\nTIME CONTEXT: ${timeDirective}` before the character description line.

**Step 5: Run full test suite**

Run: `cd server && npx vitest run`
Expected: All tests pass

**Step 6: Commit**

```bash
git add server/src/events/conversations/ai_response.ts server/tests/events/conversations/time-awareness.test.ts
git commit -m "feat(conversations): full time-of-day awareness in NPC responses"
```

---

## Task 5: NPC-Initiated Messages

Create the system for NPCs to text the player first based on realistic triggers.

**Files:**
- Create: `server/src/events/conversations/npc_initiative.ts` (new)
- Modify: `server/src/game/PlayerSession.ts:143` (hook into processHourTick)
- Test: `server/tests/events/conversations/npc-initiative.test.ts` (new)

**Step 1: Write the failing tests**

Create `server/tests/events/conversations/npc-initiative.test.ts`:

```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestPlayer, createTestCharacter } from '../../utils/conversationTestUtils.js';
import type { Player, Person } from '../../../src/models/index.js';

describe('NPC-Initiated Messages', () => {
  let player: Player;
  let friend: Person;
  let acquaintance: Person;
  let partner: Person;

  beforeEach(() => {
    friend = createTestCharacter({ firstname: 'Sara', affinity: 70, relationships: ['friend'] });
    acquaintance = createTestCharacter({ firstname: 'Bob', affinity: 20, relationships: ['acquaintance'] });
    partner = createTestCharacter({ firstname: 'Alex', affinity: 85, relationships: ['girlfriend'] });
    player = createTestPlayer({ relationships: [friend, acquaintance, partner] });
    player.date = '2024-06-15';
    player.c.birthday = '06-15';
  });

  describe('trigger detection', () => {
    it('should detect birthday trigger when player birthday matches date', () => {
      // Birthday is today (06-15)
      const triggers = detectNPCTriggers(player, friend);
      expect(triggers.some(t => t.type === 'birthday')).toBe(true);
    });

    it('should NOT trigger birthday for low-affinity acquaintance', () => {
      const triggers = detectNPCTriggers(player, acquaintance);
      expect(triggers.some(t => t.type === 'birthday')).toBe(false);
    });

    it('should detect time gap trigger for high-affinity character', () => {
      // Simulate 5 days without messaging
      friend.lastConversationDate = '2024-06-10';
      const triggers = detectNPCTriggers(player, friend);
      expect(triggers.some(t => t.type === 'time_gap')).toBe(true);
    });

    it('should NOT trigger time gap for low-affinity character', () => {
      acquaintance.lastConversationDate = '2024-06-10';
      const triggers = detectNPCTriggers(player, acquaintance);
      expect(triggers.some(t => t.type === 'time_gap')).toBe(false);
    });
  });

  describe('throttling', () => {
    it('should limit to 2 NPC-initiated messages per game day', () => {
      // This tests the throttle counter
      const state = { messagesThisDay: 0, lastDay: player.date };
      expect(state.messagesThisDay < 2).toBe(true);
      state.messagesThisDay = 2;
      expect(state.messagesThisDay < 2).toBe(false);
    });
  });

  describe('time appropriateness', () => {
    it('should not send NPC messages during deep night (2am-6am)', () => {
      const isAppropriateHour = (hour: number) => hour >= 7 && hour <= 22;
      expect(isAppropriateHour(3)).toBe(false);
      expect(isAppropriateHour(14)).toBe(true);
      expect(isAppropriateHour(7)).toBe(true);
      expect(isAppropriateHour(23)).toBe(false);
    });
  });
});

// Placeholder — will be imported from npc_initiative.ts once created
function detectNPCTriggers(player: any, character: any): Array<{ type: string }> {
  const triggers: Array<{ type: string }> = [];
  const affinity = character.affinity ?? 0;
  const relationships = character.relationships ?? [];
  const isClose = affinity > 40 || ['spouse', 'girlfriend', 'boyfriend', 'partner', 'mother', 'father', 'sibling'].some((r: string) => relationships.includes(r));

  // Birthday check
  if (player.c.birthday && player.date) {
    const monthDay = player.date.slice(5); // "MM-DD"
    if (monthDay === player.c.birthday && isClose) {
      triggers.push({ type: 'birthday' });
    }
  }

  // Time gap check (3+ days without messaging, affinity > 60)
  if (affinity > 60 && character.lastConversationDate) {
    const lastDate = new Date(character.lastConversationDate);
    const currentDate = new Date(player.date);
    const daysDiff = Math.floor((currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
    if (daysDiff >= 3) {
      triggers.push({ type: 'time_gap' });
    }
  }

  return triggers;
}
```

**Step 2: Run tests to verify they pass (self-contained with inline function)**

Run: `cd server && npx vitest run tests/events/conversations/npc-initiative.test.ts`
Expected: PASS

**Step 3: Create `server/src/events/conversations/npc_initiative.ts`**

```typescript
/**
 * NPC-Initiated Messages
 *
 * NPCs can text the player first based on realistic triggers:
 * - Birthdays (close friends/family/partner)
 * - Time gaps (3+ days without contact, high affinity)
 * - After fights (affinity dropped >15 in one conversation)
 * - Morning texts (partner/spouse, affinity >70)
 * - Shared memories (from CharacterMemory facts)
 */

import type { Player } from '../../models/Player.js';
import type { Person } from '../../models/Person.js';
import type { PlayerSession } from '../../game/PlayerSession.js';
import { ConversationObj } from './types.js';
import { getOpenAIResponse } from './ai_response.js';

interface NPCTrigger {
  type: 'birthday' | 'time_gap' | 'post_fight' | 'morning_text' | 'shared_memory';
  character: Person;
  prompt: string;
  priority: number;
}

interface NPCInitiativeState {
  messagesThisDay: number;
  lastDay: string;
  lastInitiatedByCharacter: Map<string, string>; // characterId -> last date
}

// State tracked per player (not persisted — resets on restart)
const playerNPCState = new Map<string, NPCInitiativeState>();

function getState(playerId: string, currentDay: string): NPCInitiativeState {
  let state = playerNPCState.get(playerId);
  if (!state || state.lastDay !== currentDay) {
    state = {
      messagesThisDay: 0,
      lastDay: currentDay,
      lastInitiatedByCharacter: state?.lastInitiatedByCharacter ?? new Map(),
    };
    playerNPCState.set(playerId, state);
  }
  return state;
}

export function detectNPCTriggers(player: Player, character: Person): NPCTrigger[] {
  const triggers: NPCTrigger[] = [];
  const affinity = character.affinity ?? 0;
  const relationships = character.relationships ?? [];
  const isClose = affinity > 40 ||
    ['spouse', 'girlfriend', 'boyfriend', 'partner', 'mother', 'father', 'sibling', 'best_friend']
      .some(r => relationships.includes(r));
  const isPartner = ['spouse', 'girlfriend', 'boyfriend', 'partner']
    .some(r => relationships.includes(r));

  // Birthday check — close characters always know birthdays
  if (player.c.birthday && player.date && isClose) {
    const monthDay = player.date.slice(5); // "MM-DD"
    if (monthDay === player.c.birthday) {
      triggers.push({
        type: 'birthday',
        character,
        prompt: `It's the player's birthday today! Send them a birthday message. Be warm and genuine. Your relationship: ${relationships.join(', ')}. Affinity: ${affinity}/100.`,
        priority: 10,
      });
    }
  }

  // Time gap check — high-affinity characters notice when you're quiet
  if (affinity > 60 && character.lastConversationDate) {
    const lastDate = new Date(character.lastConversationDate);
    const currentDate = new Date(player.date);
    const daysDiff = Math.floor((currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
    if (daysDiff >= 3) {
      triggers.push({
        type: 'time_gap',
        character,
        prompt: `You haven't heard from the player in ${daysDiff} days. Check in on them naturally. Don't be clingy. Your relationship: ${relationships.join(', ')}. Affinity: ${affinity}/100.`,
        priority: 5,
      });
    }
  }

  // Morning text — partners with high affinity
  if (isPartner && affinity > 70 && player.hourOfDay >= 7 && player.hourOfDay <= 9) {
    triggers.push({
      type: 'morning_text',
      character,
      prompt: `Send a sweet morning text to your partner. Keep it natural and short. Affinity: ${affinity}/100.`,
      priority: 3,
    });
  }

  return triggers;
}

/**
 * Check and send NPC-initiated messages during the hour tick.
 * Max 2 per game day, respects time-of-day constraints.
 */
export async function checkNPCInitiatedMessages(
  player: Player,
  session: PlayerSession
): Promise<void> {
  // Only fire during appropriate hours (7am-10pm)
  if (player.hourOfDay < 7 || player.hourOfDay > 22) return;

  const state = getState(player.userId, player.date);
  if (state.messagesThisDay >= 2) return;

  // Check all relationships for triggers
  const allTriggers: NPCTrigger[] = [];
  for (const character of (player.r ?? [])) {
    if (!character.id || character.status === 'dead') continue;

    // Check cooldown — each character can only initiate once per 2 days
    const lastInitiated = state.lastInitiatedByCharacter.get(character.id);
    if (lastInitiated) {
      const daysSince = Math.floor(
        (new Date(player.date).getTime() - new Date(lastInitiated).getTime()) / (1000 * 60 * 60 * 24)
      );
      if (daysSince < 2) continue;
    }

    const triggers = detectNPCTriggers(player, character);
    allTriggers.push(...triggers);
  }

  if (allTriggers.length === 0) return;

  // Sort by priority (highest first) and pick one
  allTriggers.sort((a, b) => b.priority - a.priority);
  const trigger = allTriggers[0];

  try {
    // Find or create conversation
    let convo = player.conversations?.find(
      c => c.character === trigger.character.id || (c.character as any)?.id === trigger.character.id
    );
    if (!convo) {
      convo = new ConversationObj(trigger.character, 'chat');
      if (!player.conversations) player.conversations = [];
      player.conversations.push(convo);
    }

    // Generate AI message for the NPC
    await getOpenAIResponse(convo, trigger.character, player, trigger.prompt);

    // Mark as unread and send to client
    convo.unread = true;
    session.send(convo.toJSON());

    // Update throttle state
    state.messagesThisDay++;
    state.lastInitiatedByCharacter.set(trigger.character.id, player.date);

    console.log(`NPC initiated message: ${trigger.character.firstname} (${trigger.type})`);
  } catch (error) {
    console.log(`NPC initiative failed for ${trigger.character.firstname}: ${error}`);
  }
}
```

**Step 4: Hook into PlayerSession.processHourTick()**

In `server/src/game/PlayerSession.ts`, add import at top:
```typescript
import { checkNPCInitiatedMessages } from '../events/conversations/npc_initiative.js';
```

After line 164 (after message queue processing), add:
```typescript
    // Check for NPC-initiated messages (max 2/day, appropriate hours only)
    try {
      await checkNPCInitiatedMessages(this.player, this);
    } catch (error) {
      console.log(`NPC initiative check error: ${error}`);
    }
```

Note: `processHourTick()` may need to become `async` if it isn't already. Check the function signature.

**Step 5: Run full test suite**

Run: `cd server && npx vitest run`
Expected: All tests pass

**Step 6: Commit**

```bash
git add server/src/events/conversations/npc_initiative.ts server/src/game/PlayerSession.ts server/tests/events/conversations/npc-initiative.test.ts
git commit -m "feat(conversations): NPC-initiated messages with trigger system"
```

---

## Task 6: iOS — affinityDelta in Conversation Model

Add the `affinityDelta` field to the iOS `ConversationMessage` model so the UI can display real delta values.

**Files:**
- Modify: `ios/lichunWebsocket/Core/Models/Conversation.swift:25-38` (ConversationMessage)

**Step 1: Add affinityDelta property**

In `Conversation.swift`, add to the ConversationMessage properties (after line 28, the `sentiment` property):

```swift
let affinityDelta: Int?
```

Also make sure the CodingKeys enum (if present) or the Decodable conformance includes this field. If `ConversationMessage` uses automatic Codable synthesis, just adding the property is enough since it's optional.

**Step 2: Parse affinityDelta from message data**

Check if `ConversationMessage` has a custom `init(from decoder:)`. If it does, add `affinityDelta` parsing. If it uses a `data` dictionary, extract `affinityDelta` from the `data` field:

```swift
// In init or wherever message data is parsed:
if let data = data as? [String: Any] {
    self.affinityDelta = data["affinityDelta"] as? Int
}
```

**Step 3: Build to verify**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme BaoLife -destination 'platform=iOS Simulator,id=265DE570-7AC8-4078-880D-BA7BA64FEAF7' -configuration Debug build 2>&1 | grep -E '(BUILD|error:)'`
Expected: BUILD SUCCEEDED

**Step 4: Commit**

```bash
git add ios/lichunWebsocket/Core/Models/Conversation.swift
git commit -m "feat(ios): add affinityDelta to ConversationMessage model"
```

---

## Task 7: iOS — Animated Affinity Feedback in Chat Header

Add pulse animation and floating delta to the affinity pill when it changes during a conversation.

**Files:**
- Modify: `ios/lichunWebsocket/Features/Messaging/Components/ChatHeaderCard.swift:230-239` (affinity display)

**Step 1: Add state tracking for affinity changes**

Add to ChatHeaderCard properties:

```swift
@State private var previousAffinity: Int = 0
@State private var showAffinityDelta: Bool = false
@State private var affinityDeltaValue: Int = 0
@State private var affinityPulse: Bool = false
```

**Step 2: Replace the static affinity display with animated version**

Replace the affinity HStack in fullHeader (lines 230-239) with:

```swift
HStack(spacing: 4) {
    Image(systemName: "heart.fill")
        .font(.system(size: 11))
        .foregroundColor(affinityColor)
        .scaleEffect(affinityPulse ? 1.4 : 1.0)
        .animation(.spring(response: 0.3, dampingFraction: 0.5), value: affinityPulse)

    Text("\(character.affinity)")
        .font(.appCaption)
        .fontWeight(.semibold)
        .foregroundColor(affinityColor)
        .contentTransition(.numericText())

    if showAffinityDelta {
        Text(affinityDeltaValue > 0 ? "+\(affinityDeltaValue)" : "\(affinityDeltaValue)")
            .font(.system(size: 10, weight: .bold))
            .foregroundColor(affinityDeltaValue > 0 ? .green : .red)
            .transition(.asymmetric(
                insertion: .scale.combined(with: .opacity),
                removal: .opacity
            ))
    }
}
.onChange(of: character.affinity) { oldValue, newValue in
    let delta = newValue - oldValue
    if delta != 0 {
        affinityDeltaValue = delta
        withAnimation(.spring(response: 0.3)) {
            showAffinityDelta = true
            affinityPulse = true
        }
        // Reset pulse
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            affinityPulse = false
        }
        // Hide delta after 2 seconds
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            withAnimation(.easeOut(duration: 0.3)) {
                showAffinityDelta = false
            }
        }
    }
}
```

**Step 3: Build to verify**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme BaoLife -destination 'platform=iOS Simulator,id=265DE570-7AC8-4078-880D-BA7BA64FEAF7' -configuration Debug build 2>&1 | grep -E '(BUILD|error:)'`
Expected: BUILD SUCCEEDED

**Step 4: Commit**

```bash
git add ios/lichunWebsocket/Features/Messaging/Components/ChatHeaderCard.swift
git commit -m "feat(ios): animated affinity feedback with pulse and floating delta"
```

---

## Task 8: iOS — Dynamic Sentiment Badges on Message Bubbles

Replace flat "+5"/"-5" with real affinity delta values from the AI, scaled by magnitude.

**Files:**
- Modify: `ios/lichunWebsocket/Features/Messaging/Components/CozyMessageBubble.swift:174-226` (sentimentIndicator)

**Step 1: Rewrite the sentimentIndicator computed property**

Replace lines 174-226 with:

```swift
@ViewBuilder
private var sentimentIndicator: some View {
    let delta = message.affinityDelta ?? (message.sentiment == "positive" ? 5 : message.sentiment == "negative" ? -5 : 0)

    if delta != 0 {
        let magnitude = abs(delta)
        let isPositive = delta > 0

        HStack(spacing: 2) {
            Image(systemName: isPositive ? "heart.fill" : "heart.slash.fill")
                .font(.system(size: magnitude > 15 ? 12 : 10))

            Text(isPositive ? "+\(delta)" : "\(delta)")
                .font(.system(size: magnitude > 15 ? 12 : 10, weight: magnitude > 15 ? .bold : .medium))
        }
        .foregroundColor(badgeColor(delta: delta))
        .padding(.horizontal, 6)
        .padding(.vertical, 2)
        .background(
            Capsule()
                .fill(badgeColor(delta: delta).opacity(0.15))
        )
    }
}

private func badgeColor(delta: Int) -> Color {
    if delta >= 16 { return Color.green }
    if delta >= 6 { return AppColors.friend }
    if delta >= 1 { return AppColors.friend.opacity(0.7) }
    if delta >= -8 { return Color.orange }
    if delta >= -20 { return AppColors.error }
    return AppColors.error.opacity(0.9) // -21 to -50
}
```

**Step 2: Build to verify**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme BaoLife -destination 'platform=iOS Simulator,id=265DE570-7AC8-4078-880D-BA7BA64FEAF7' -configuration Debug build 2>&1 | grep -E '(BUILD|error:)'`
Expected: BUILD SUCCEEDED

**Step 3: Commit**

```bash
git add ios/lichunWebsocket/Features/Messaging/Components/CozyMessageBubble.swift
git commit -m "feat(ios): dynamic sentiment badges showing real affinity deltas"
```

---

## Task 9: iOS — Energy Flash Feedback

Add a brief flash animation to the energy pill when the player sends a message (costing 10 energy).

**Files:**
- Modify: `ios/lichunWebsocket/Features/Messaging/Views/ChatView.swift` (sendMessage function)
- Modify: `ios/lichunWebsocket/Features/Messaging/Components/ChatHeaderCard.swift` (energy pill)

**Step 1: Add energy flash state to ChatView**

Add state variable near other @State properties:
```swift
@State private var energyFlash: Bool = false
```

**Step 2: Pass energyFlash binding to ChatHeaderCard**

Update ChatHeaderCard to accept an optional binding:
```swift
var energyFlash: Binding<Bool>?
```

In the energy ResourcePill, add a flash overlay:
```swift
ResourcePill(icon: "bolt.fill", value: "\(webSocketService.person.calcEnergy)", color: AppColors.energy)
    .overlay(
        RoundedRectangle(cornerRadius: 8)
            .fill(AppColors.energy.opacity(energyFlash?.wrappedValue == true ? 0.3 : 0))
            .animation(.easeOut(duration: 0.3), value: energyFlash?.wrappedValue)
    )
```

**Step 3: Trigger flash in sendMessage**

In ChatView's `sendMessage()` function, after sending the message:
```swift
// Flash energy indicator
withAnimation(.easeIn(duration: 0.1)) {
    energyFlash = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
    withAnimation(.easeOut(duration: 0.2)) {
        energyFlash = false
    }
}
```

**Step 4: Build to verify**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme BaoLife -destination 'platform=iOS Simulator,id=265DE570-7AC8-4078-880D-BA7BA64FEAF7' -configuration Debug build 2>&1 | grep -E '(BUILD|error:)'`
Expected: BUILD SUCCEEDED

**Step 5: Commit**

```bash
git add ios/lichunWebsocket/Features/Messaging/Views/ChatView.swift ios/lichunWebsocket/Features/Messaging/Components/ChatHeaderCard.swift
git commit -m "feat(ios): energy flash feedback when sending messages"
```

---

## Task 10: Final Integration Test & Verification

Run all tests, verify builds, and do a manual smoke test on the simulator.

**Files:**
- No new files

**Step 1: Run backend tests**

Run: `cd server && npx vitest run`
Expected: All tests pass (1050+ tests)

**Step 2: TypeScript compilation check**

Run: `cd server && npx tsc --noEmit`
Expected: Clean, no errors

**Step 3: iOS build check**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme BaoLife -destination 'platform=iOS Simulator,id=265DE570-7AC8-4078-880D-BA7BA64FEAF7' -configuration Debug build 2>&1 | grep -E '(BUILD|error:)'`
Expected: BUILD SUCCEEDED

**Step 4: Manual smoke test**

1. Install and launch app on simulator
2. Open a conversation with a character
3. Send a casual message ("hey") — verify short response
4. Send an emotional message ("I've been really worried about us") — verify longer response
5. Check affinity pill animates when response arrives
6. Check message badge shows actual delta (not flat +5)
7. Check energy flashes when you send

**Step 5: Final commit**

```bash
git add -A
git commit -m "feat: messaging & communications overhaul — dynamic affinity, smart lengths, NPC initiative, time awareness, live feedback"
```
