# Friendship V2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A real friendship system (Option B): stored tiers with milestones, neglect drift/fade, tier-gated payoffs, NPC-bound v2 events with real choices, a "hang out" player action, and deletion of the dead 1,139-line `friendships.ts`.

**Architecture:** A new `friendship_manager` service runs in BOTH weekly ticks (online `PlayerSession`, offline `GameEngine`) handling tiers/drift/payoffs/beats. The v2 event engine gains a reusable NPC-binding mechanism (`selectTarget` + `{target}` interpolation + `effects.target`) that powers 5 new friendship catalog events. A `hangOut` WS command gives the player direct agency. iOS shows tiers and wires the Hang Out button.

**Tech Stack:** TypeScript (server, `tsx`), Vitest, MySQL (player JSON blob), SwiftUI (iOS).

**Working dir:** `/Users/craigvandergalien/Documents/GitHub/lichun/.claude/worktrees/friendship-v2` (worktree off `origin/main` @ `edfab780`). All server commands run from `server/`.

**Standing constraints (from project memory — non-negotiable):**
- Mirror every loop-touching change in BOTH `PlayerSession` (online) and `GameEngine` (offline).
- Pushing `main` auto-deploys to production via webhook. Do NOT push until the whole plan is verified and Craig approves.
- Never touch legacy `ws/`. Never commit avatar WIP files (`MainCharacterView.swift`, `QuickStatsCard.swift`, `avatarLibrary.*`) — they should not appear in this worktree anyway.
- New WS commands need: handler + `COMMAND_REGISTRY` + `contracts/websocket-commands.ts` + `tests/contracts/fixtures/commands.json`.
- Trust `npx tsc --noEmit`, not editor diagnostics.

**Verification baseline (confirmed 2026-06-10):** `npx tsc --noEmit` clean; `npx vitest run` 1763/1763 passing.

---

## Design Reference (read before any task)

### Tier model
Stored as `friendshipTier` on `Person` (server) for friend-tagged NPCs only.

| Tier | Promote at affinity ≥ | Demote below (hysteresis) |
|---|---|---|
| `acquaintance` | (base) | — |
| `friend` | 30 | 20 |
| `close` | 60 | 50 |
| `best` | 85 **and** no other living best friend | 75 |

Friend tags: `'friend' | 'best friend' | 'bestfriend' | 'bff'` (matches existing data — see `character_manager.addFriend` which pushes `'friend'`, and the dead `BestFriendMilestone` which used `'bestfriend'`). Family is explicitly NOT in scope of tiers (family beats stay in T9's path).

### Day basis
All "days" are `player.c.ageDays` (monotonic; same basis the v2 engine uses for cooldowns — see `EventEngine.resolveCurrentDay`). New per-NPC day fields: `lastFriendInteractionDay`, `lastHangOutDay`. New player-level field: `lastFriendBailoutDay`.

### Weekly friendship tick (order matters)
1. Normalize: NPC with friend tag but `friendshipTier === undefined` → derive tier from affinity **silently** (no transition messages on first normalization of old saves; also init `lastFriendInteractionDay` to today so old saves don't instantly mass-decay).
2. Neglect drift: > 28 days since last interaction → affinity −2 this week.
3. Fade: affinity ≤ 0 AND > 84 days neglected → remove friend tags, message, tier cleared.
4. Tier recompute + transition messages (promotion/demotion).
5. Tier passives: each `close` friend +1 happiness (max +2/week total); a `best` friend additionally −2 stress, +1 happiness.
6. Best-friend bailout: money < 0, a living best friend with affinity ≥ 75, ≥ 365 days since last bailout → gift `min(600, -money + 200)`, +3 affinity, +3 happiness, message.
7. Tier-aware weekly beat (replaces T9's friend half): one beat for one random friend; 10% chance (18% if circle has a close/best friend), close/best get richer text and +1 bonus affinity. Positive beats stamp `lastFriendInteractionDay`.

### v2 NPC binding (the keystone — reusable for family/coworker later)
- `EventDefinition.selectTarget?: (player) => { personId, name } | null` — when present, returning `null` makes the event ineligible.
- At fire time the engine binds ONE target, stores `{ targetPersonId, targetName }` in the instance `context` (already persisted in `event_instances.context_json` and already returned by `getPendingEventInstances` — the responder interface just doesn't declare it).
- `{target}` placeholder interpolated into prompt, choice text, and resolution text.
- `EventEffects.target?: { affinityDelta: number }` — translated to a concrete `relationships` entry against the bound personId at apply time. A positive delta also stamps `lastFriendInteractionDay`.

---

# PHASE 1 — Server model + friendship manager

### Task 1: Person + Player model fields

**Files:**
- Modify: `server/src/models/Person.ts`
- Modify: `server/src/models/Player.ts`
- Test: `server/tests/models/friendship-fields.test.ts` (create)

- [ ] **Step 1: Write the failing test**

```typescript
// server/tests/models/friendship-fields.test.ts
import { describe, it, expect } from 'vitest';
import { Person } from '../../src/models/Person.js';
import { Player } from '../../src/models/Player.js';

describe('friendship persistence fields', () => {
  it('Person round-trips friendshipTier, lastFriendInteractionDay, lastHangOutDay through toJSON', () => {
    const p = new Person({ firstname: 'Ana', sex: 'female' } as any);
    p.friendshipTier = 'close';
    p.lastFriendInteractionDay = 1234;
    p.lastHangOutDay = 1230;

    const revived = new Person(p.toJSON() as any);
    expect(revived.friendshipTier).toBe('close');
    expect(revived.lastFriendInteractionDay).toBe(1234);
    expect(revived.lastHangOutDay).toBe(1230);
  });

  it('Person defaults the new fields to undefined (old saves unaffected)', () => {
    const p = new Person({ firstname: 'Bo', sex: 'male' } as any);
    expect(p.friendshipTier).toBeUndefined();
    expect(p.lastFriendInteractionDay).toBeUndefined();
    expect(p.lastHangOutDay).toBeUndefined();
  });

  it('Player round-trips lastFriendBailoutDay through toJSON', () => {
    const player = new Player({ id: 'test-bailout' } as any);
    player.lastFriendBailoutDay = 5000;
    const json = player.toJSON() as any;
    expect(json.lastFriendBailoutDay).toBe(5000);

    const revived = new Player(json);
    expect(revived.lastFriendBailoutDay).toBe(5000);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd server && npx vitest run tests/models/friendship-fields.test.ts`
Expected: FAIL (`friendshipTier` does not exist / values undefined after round-trip).

- [ ] **Step 3: Add fields to Person.ts (four touches each)**

In `server/src/models/Person.ts`:

(a) In the `PersonData` interface, directly after the `affinityWasHigh?: boolean;` member (~line 192), add:

```typescript
  // Friendship V2 (friend-tagged NPCs only; family/romance are out of scope).
  // friendshipTier: stored tier with hysteresis — recomputed by the weekly
  //   friendship tick from affinity (promote 30/60/85, demote 20/50/75).
  // lastFriendInteractionDay: player.c.ageDays of the last POSITIVE interaction
  //   (hang out, positive weekly beat, positive bound-event choice). Drives
  //   neglect drift (>28d) and fade (>84d at affinity <= 0).
  // lastHangOutDay: player.c.ageDays of the last hangOut command against this
  //   NPC; enforces the 3-day per-NPC cooldown.
  friendshipTier?: 'acquaintance' | 'friend' | 'close' | 'best';
  lastFriendInteractionDay?: number;
  lastHangOutDay?: number;
```

(b) In the `Person` class field declarations, after `affinityWasHigh?: boolean;` (~line 362), add:

```typescript
  // Friendship V2 (see PersonData for semantics).
  friendshipTier?: 'acquaintance' | 'friend' | 'close' | 'best';
  lastFriendInteractionDay?: number;
  lastHangOutDay?: number;
```

(c) In the constructor, next to where `this.affinityWasHigh` is restored (~line 509), add:

```typescript
    this.friendshipTier = data.friendshipTier;
    this.lastFriendInteractionDay = data.lastFriendInteractionDay;
    this.lastHangOutDay = data.lastHangOutDay;
```

(d) In `toJSON()`, next to the `lastPositiveInteraction: this.lastPositiveInteraction,` entry, add:

```typescript
      friendshipTier: this.friendshipTier,
      lastFriendInteractionDay: this.lastFriendInteractionDay,
      lastHangOutDay: this.lastHangOutDay,
```

- [ ] **Step 4: Add `lastFriendBailoutDay` to Player.ts (three touches)**

In `server/src/models/Player.ts`:

(a) Class field — near the other simple player fields (e.g. near `relationships: RelationshipPerson[];` ~line 357):

```typescript
  // Friendship V2: player.c.ageDays of the last best-friend bailout, so the
  // payoff fires at most once per in-game year.
  lastFriendBailoutDay?: number;
```

(b) Declare `lastFriendBailoutDay?: number;` in the `PlayerData` interface too (near `deletionScheduledAt`), then restore in the constructor with the other simple field restores:

```typescript
    this.lastFriendBailoutDay = data.lastFriendBailoutDay;
```

(c) `toJSON()` — alongside the other scalar entries (~line 607):

```typescript
      lastFriendBailoutDay: this.lastFriendBailoutDay,
```

- [ ] **Step 5: Run test to verify it passes**

Run: `cd server && npx vitest run tests/models/friendship-fields.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 6: Typecheck and commit**

```bash
cd server && npx tsc --noEmit
git add src/models/Person.ts src/models/Player.ts tests/models/friendship-fields.test.ts
git commit -m "feat(friendship): persistence fields for tiers, interaction tracking, bailout"
```

---

### Task 2: friendship_manager — tier engine

**Files:**
- Create: `server/src/services/relationships/friendship_manager.ts`
- Test: `server/tests/services/friendship-manager.test.ts` (create)

- [ ] **Step 1: Write the failing tests**

```typescript
// server/tests/services/friendship-manager.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Player } from '../../src/models/Player.js';
import { Person } from '../../src/models/Person.js';
import {
  computeTier,
  isFriendNpc,
  processWeeklyFriendshipTick,
  FRIENDSHIP_TAGS,
} from '../../src/services/relationships/friendship_manager.js';

function makePlayer(): Player {
  const player = new Player({ id: 'fr-test' } as any);
  player.c = new Person({ firstname: 'Hero', sex: 'female' } as any);
  player.c.ageDays = 7300; // ~20 years
  player.c.ageYears = 20;
  player.c.happiness = 50;
  player.c.stress = 50;
  player.c.money = 100;
  player.r = [];
  return player;
}

function addFriend(player: Player, affinity: number, tier?: Person['friendshipTier']): Person {
  const friend = new Person({ firstname: 'Ana', sex: 'female' } as any);
  friend.id = `friend-${player.r.length}`;
  friend.status = 'alive';
  friend.relationships = ['friend'];
  friend.affinity = affinity;
  friend.friendshipTier = tier;
  friend.lastFriendInteractionDay = player.c.ageDays; // recently active by default
  player.r.push(friend);
  return friend;
}

afterEach(() => vi.restoreAllMocks());

describe('computeTier', () => {
  it('promotes at 30/60/85 and holds with hysteresis until 20/50/75', () => {
    expect(computeTier(10, undefined, false)).toBe('acquaintance');
    expect(computeTier(30, undefined, false)).toBe('friend');
    expect(computeTier(60, undefined, false)).toBe('close');
    expect(computeTier(85, undefined, false)).toBe('best');
    // hysteresis: a close friend at 55 stays close (demote only below 50)
    expect(computeTier(55, 'close', false)).toBe('close');
    expect(computeTier(49, 'close', false)).toBe('friend');
    // best at 80 stays best; below 75 drops
    expect(computeTier(80, 'best', false)).toBe('best');
    expect(computeTier(74, 'best', false)).toBe('close');
  });

  it('caps best at one: 85+ affinity stays close when a best slot is taken', () => {
    expect(computeTier(90, 'close', true)).toBe('close');
  });
});

describe('isFriendNpc', () => {
  it('matches friend tags, not family', () => {
    const p = new Person({ firstname: 'X', sex: 'male' } as any);
    p.relationships = ['friend'];
    expect(isFriendNpc(p)).toBe(true);
    p.relationships = ['bestfriend'];
    expect(isFriendNpc(p)).toBe(true);
    p.relationships = ['sibling'];
    expect(isFriendNpc(p)).toBe(false);
  });
});

describe('processWeeklyFriendshipTick — tiers', () => {
  it('silently normalizes undefined tiers on old saves (no messages)', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99); // suppress beats
    const player = makePlayer();
    const f = addFriend(player, 70); // tier undefined
    f.lastFriendInteractionDay = undefined; // old save
    const events = processWeeklyFriendshipTick(player);
    expect(f.friendshipTier).toBe('close');
    expect(f.lastFriendInteractionDay).toBe(player.c.ageDays); // initialized
    expect(events).toHaveLength(0);
  });

  it('fires a promotion message when affinity crosses a tier up', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    const f = addFriend(player, 65, 'friend');
    const events = processWeeklyFriendshipTick(player);
    expect(f.friendshipTier).toBe('close');
    expect(events.some((e) => 'message' in e && e.message.includes('Ana'))).toBe(true);
  });

  it('only one best friend at a time', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    addFriend(player, 90, 'best');
    const second = addFriend(player, 95, 'close');
    processWeeklyFriendshipTick(player);
    expect(second.friendshipTier).toBe('close');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd server && npx vitest run tests/services/friendship-manager.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Implement the tier engine**

Create `server/src/services/relationships/friendship_manager.ts`:

```typescript
/**
 * Friendship V2 — a real friendship SYSTEM (tiers, neglect, payoffs, beats).
 *
 * Romance has a legible progression (Prospect→Dating→Engaged→Married, stored in
 * player.relData). Friendship was just an affinity number that drifted. This
 * manager gives friend-tagged NPCs (player.r) a stored tier with hysteresis,
 * interaction-gated neglect drift/fade, tier-gated payoffs (close-friend
 * happiness buffer, best-friend bailout), and the tier-aware weekly beat that
 * supersedes the friend half of T9's processWeeklyFriendEvents.
 *
 * Runs from BOTH weekly ticks: online PlayerSession.processWeekTick and offline
 * GameEngine.handleWeeklyUpdates — keep the two call sites mirrored.
 *
 * Day basis: player.c.ageDays (monotonic; same basis as v2 event cooldowns).
 */
import { Player } from '../../models/Player.js';
import { Person } from '../../models/Person.js';
import { createMessageEvent, EventResult } from '../../events/base.js';
import { getActiveRelationship } from './relationship_manager.js';
import { clamp } from '../../utils/statUtils.js';

export type FriendshipTier = 'acquaintance' | 'friend' | 'close' | 'best';

/** Tags that mark an NPC as a (non-family) friend. Family beats stay in T9. */
export const FRIENDSHIP_TAGS = new Set(['friend', 'best friend', 'bestfriend', 'bff']);

// Promote thresholds (affinity >=) and demote thresholds (affinity <) per tier.
const PROMOTE = { friend: 30, close: 60, best: 85 } as const;
const DEMOTE = { friend: 20, close: 50, best: 75 } as const;

export const NEGLECT_DRIFT_AFTER_DAYS = 28;
export const NEGLECT_DRIFT_PER_WEEK = 2;
export const FADE_AFTER_DAYS = 84;
export const BAILOUT_COOLDOWN_DAYS = 365;
export const BAILOUT_MAX = 600;

export function isFriendNpc(person: Person): boolean {
  return (
    person.status === 'alive' &&
    (person.relationships ?? []).some((t) => FRIENDSHIP_TAGS.has(t.toLowerCase()))
  );
}

/**
 * Tier from affinity with hysteresis: promote at PROMOTE thresholds, but once
 * in a tier, only demote when affinity falls below the (lower) DEMOTE
 * threshold. `bestSlotTaken` enforces the one-best-friend cap: a candidate
 * can't be PROMOTED into 'best' while another living friend holds it (an NPC
 * already 'best' keeps it subject to its own demote threshold).
 */
export function computeTier(
  affinity: number,
  current: FriendshipTier | undefined,
  bestSlotTaken: boolean
): FriendshipTier {
  if (current === 'best') {
    if (affinity >= DEMOTE.best) return 'best';
    return affinity >= DEMOTE.close ? 'close' : affinity >= DEMOTE.friend ? 'friend' : 'acquaintance';
  }
  if (affinity >= PROMOTE.best && !bestSlotTaken) return 'best';
  if (current === 'close') {
    if (affinity >= DEMOTE.close) return 'close';
    return affinity >= DEMOTE.friend ? 'friend' : 'acquaintance';
  }
  if (affinity >= PROMOTE.close) return 'close';
  if (current === 'friend') {
    return affinity >= DEMOTE.friend ? 'friend' : 'acquaintance';
  }
  return affinity >= PROMOTE.friend ? 'friend' : 'acquaintance';
}

const TIER_LABEL: Record<FriendshipTier, string> = {
  acquaintance: 'acquaintances',
  friend: 'friends',
  close: 'close friends',
  best: 'best friends',
};

const TIER_RANK: Record<FriendshipTier, number> = {
  acquaintance: 0,
  friend: 1,
  close: 2,
  best: 3,
};

/** Stamp a positive interaction (resets neglect). Exported for hangOut + v2 effects. */
export function recordFriendInteraction(player: Player, person: Person): void {
  person.lastFriendInteractionDay = Math.floor(player.c.ageDays ?? 0);
}

function currentDay(player: Player): number {
  return Math.floor(player.c.ageDays ?? 0);
}

/**
 * The weekly friendship tick. Order: normalize -> neglect/fade -> tier
 * transitions -> passives -> bailout -> beat. Returns message EventResults for
 * the caller to drain onto player.messageQueue (same contract as
 * processWeeklyRelationshipEvents / processWeeklyFriendEvents).
 */
export function processWeeklyFriendshipTick(player: Player): EventResult[] {
  const events: EventResult[] = [];
  const today = currentDay(player);

  const activeRel = getActiveRelationship(player);
  const partnerId = activeRel?.person2;

  const friends = (player.r ?? []).filter((p) => p.id !== partnerId && isFriendNpc(p));
  if (friends.length === 0) return events;

  // ── 1. Normalize old saves: derive tier silently, init interaction day. ──
  for (const f of friends) {
    if (f.lastFriendInteractionDay === undefined) {
      f.lastFriendInteractionDay = today;
    }
    if (f.friendshipTier === undefined) {
      const bestTaken = friends.some((o) => o !== f && o.friendshipTier === 'best');
      f.friendshipTier = computeTier(f.affinity ?? 50, undefined, bestTaken);
    }
  }

  // ── 2+3. Neglect drift and fade. ──
  for (const f of [...friends]) {
    const idle = today - (f.lastFriendInteractionDay ?? today);
    if (idle > NEGLECT_DRIFT_AFTER_DAYS) {
      f.affinity = clamp((f.affinity ?? 50) - NEGLECT_DRIFT_PER_WEEK, -100, 100);
    }
    if ((f.affinity ?? 0) <= 0 && idle > FADE_AFTER_DAYS) {
      f.relationships = (f.relationships ?? []).filter((t) => !FRIENDSHIP_TAGS.has(t.toLowerCase()));
      f.friendshipTier = undefined;
      const fade = createMessageEvent(
        `friendship_faded_${f.id}`,
        `You and ${f.firstname} have drifted apart for good. The friendship has faded.`,
        player,
        true,
        { title: 'Friendship' }
      );
      if (fade) events.push(fade);
    }
  }
  const remaining = friends.filter((f) => isFriendNpc(f));

  // ── 4. Tier transitions (with messages). ──
  for (const f of remaining) {
    const before = f.friendshipTier;
    const bestTaken = remaining.some((o) => o !== f && o.friendshipTier === 'best');
    const after = computeTier(f.affinity ?? 50, before, bestTaken);
    if (after !== before) {
      f.friendshipTier = after;
      const up = TIER_RANK[after] > TIER_RANK[before ?? 'acquaintance'];
      const message = up
        ? after === 'best'
          ? `You and ${f.firstname} are officially best friends. Some people just get you.`
          : `You and ${f.firstname} have become ${TIER_LABEL[after]}.`
        : `You and ${f.firstname} aren't as close as you used to be. You're more like ${TIER_LABEL[after]} now.`;
      if (up && after === 'best') {
        player.c.happiness = clamp((player.c.happiness ?? 50) + 5, 0, 100);
      }
      const evt = createMessageEvent(`friendship_tier_${after}_${f.id}`, message, player, true, {
        title: 'Friendship',
      });
      if (evt) events.push(evt);
    }
  }

  // ── 5. Tier passives: close friends buffer happiness; a best friend eases stress. ──
  const closeCount = remaining.filter((f) => f.friendshipTier === 'close').length;
  const best = remaining.find((f) => f.friendshipTier === 'best');
  const happinessBuffer = Math.min(closeCount, 2) + (best ? 1 : 0);
  if (happinessBuffer > 0) {
    player.c.happiness = clamp((player.c.happiness ?? 50) + happinessBuffer, 0, 100);
  }
  if (best) {
    player.c.stress = clamp((player.c.stress ?? 50) - 2, 0, 100);
  }

  // ── 6. Best-friend bailout: once per in-game year, when broke. ──
  const lastBailout = player.lastFriendBailoutDay;
  if (
    best &&
    (best.affinity ?? 0) >= DEMOTE.best &&
    (player.c.money ?? 0) < 0 &&
    (lastBailout === undefined || today - lastBailout >= BAILOUT_COOLDOWN_DAYS)
  ) {
    const gift = Math.min(BAILOUT_MAX, -(player.c.money ?? 0) + 200);
    player.c.money = (player.c.money ?? 0) + gift;
    player.c.happiness = clamp((player.c.happiness ?? 50) + 3, 0, 100);
    best.affinity = clamp((best.affinity ?? 50) + 3, -100, 100);
    recordFriendInteraction(player, best);
    player.lastFriendBailoutDay = today;
    const evt = createMessageEvent(
      `friendship_bailout_${best.id}_${today}`,
      `${best.firstname} noticed you were struggling and quietly covered you with $${gift}. "Pay me back in good company."`,
      player,
      true,
      { title: 'Best Friend', moneyCost: -gift }
    );
    if (evt) events.push(evt);
  }

  // ── 7. Tier-aware weekly beat (supersedes T9's friend half). ──
  const beat = rollWeeklyBeat(player, remaining);
  if (beat) events.push(beat);

  return events;
}

interface FriendBeat {
  name: string;
  message: string;
  affinityChange: number;
}

const CASUAL_BEATS: FriendBeat[] = [
  { name: 'caught_up', message: 'You caught up with {name} over coffee. Good to reconnect.', affinityChange: 3 },
  { name: 'helped_out', message: 'You helped {name} through a busy week. They really appreciated it.', affinityChange: 4 },
  { name: 'hobby_together', message: 'You and {name} spent an afternoon on a shared hobby.', affinityChange: 3 },
  { name: 'lost_touch', message: "You realized you haven't talked to {name} in a while - you've drifted a little.", affinityChange: -2 },
  { name: 'small_disagreement', message: 'You and {name} had a small disagreement, but talked it through.', affinityChange: -1 },
];

const CLOSE_BEATS: FriendBeat[] = [
  { name: 'deep_talk', message: 'You and {name} stayed up late talking about everything and nothing. The kind of talk you only have with a close friend.', affinityChange: 5 },
  { name: 'showed_up', message: '{name} showed up for you this week without being asked. They just knew.', affinityChange: 5 },
  { name: 'inside_joke', message: 'Something happened that only {name} would find funny. You sent it to them immediately.', affinityChange: 4 },
  { name: 'tradition', message: 'You and {name} kept up your little tradition this week. These things add up.', affinityChange: 4 },
  { name: 'honest_callout', message: '{name} called you out on something, honestly and kindly. It stung, but they were right.', affinityChange: 3 },
];

/** One beat for one random friend per week, tier-aware in both odds and text. */
function rollWeeklyBeat(player: Player, friends: Person[]): EventResult | null {
  if (friends.length === 0) return null;
  const hasInner = friends.some((f) => f.friendshipTier === 'close' || f.friendshipTier === 'best');
  const chance = hasInner ? 0.18 : 0.1;
  if (Math.random() > chance) return null;

  const friend = friends[Math.floor(Math.random() * friends.length)];
  const inner = friend.friendshipTier === 'close' || friend.friendshipTier === 'best';
  const pool = inner ? CLOSE_BEATS : CASUAL_BEATS;
  const beat = pool[Math.floor(Math.random() * pool.length)];
  const bonus = inner && beat.affinityChange > 0 ? 1 : 0;
  const delta = beat.affinityChange + bonus;

  friend.affinity = clamp((friend.affinity ?? 0) + delta, -100, 100);
  if (delta > 0) recordFriendInteraction(player, friend);

  return createMessageEvent(
    `friend_${beat.name}_${friend.id}`,
    beat.message.replace('{name}', friend.firstname || 'a friend'),
    player,
    true,
    { title: 'Friendship', affinityChange: delta }
  );
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd server && npx vitest run tests/services/friendship-manager.test.ts`
Expected: PASS (all tests in the file).

- [ ] **Step 5: Typecheck and commit**

```bash
cd server && npx tsc --noEmit
git add src/services/relationships/friendship_manager.ts tests/services/friendship-manager.test.ts
git commit -m "feat(friendship): tier engine with hysteresis, single best-friend cap, weekly tick core"
```

---

### Task 3: Drift, fade, passives, bailout — behavior tests

The implementation already landed in Task 2 (single cohesive module); this task pins the remaining behaviors with tests so regressions can't sneak in.

**Files:**
- Test: `server/tests/services/friendship-manager.test.ts` (extend)

- [ ] **Step 1: Append the behavior tests**

Append to `server/tests/services/friendship-manager.test.ts` (inside the file, using the existing helpers):

```typescript
describe('processWeeklyFriendshipTick — neglect, fade, payoffs', () => {
  it('drifts affinity -2/week after 28 days without interaction', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    const f = addFriend(player, 40, 'friend');
    f.lastFriendInteractionDay = player.c.ageDays - 30;
    processWeeklyFriendshipTick(player);
    expect(f.affinity).toBe(38);
  });

  it('does NOT drift recently-active friendships', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    const f = addFriend(player, 40, 'friend');
    f.lastFriendInteractionDay = player.c.ageDays - 5;
    processWeeklyFriendshipTick(player);
    expect(f.affinity).toBe(40);
  });

  it('fades a neglected dead friendship: tags removed + message', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    const f = addFriend(player, 0, 'acquaintance');
    f.lastFriendInteractionDay = player.c.ageDays - 100;
    const events = processWeeklyFriendshipTick(player);
    expect(f.relationships).not.toContain('friend');
    expect(f.friendshipTier).toBeUndefined();
    expect(events.some((e) => 'message' in e && e.message.includes('faded'))).toBe(true);
  });

  it('close friends buffer happiness; a best friend eases stress', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    addFriend(player, 70, 'close');
    addFriend(player, 70, 'close');
    addFriend(player, 90, 'best');
    player.c.happiness = 50;
    player.c.stress = 50;
    processWeeklyFriendshipTick(player);
    expect(player.c.happiness).toBe(53); // 2 close (cap 2) + 1 best
    expect(player.c.stress).toBe(48);
  });

  it('best friend bails you out when broke, once per year', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    const best = addFriend(player, 90, 'best');
    player.c.money = -150;
    const events = processWeeklyFriendshipTick(player);
    expect(player.c.money).toBe(200); // -150 + min(600, 150+200)
    expect(player.lastFriendBailoutDay).toBe(Math.floor(player.c.ageDays));
    expect(events.some((e) => 'message' in e && e.message.includes('covered you'))).toBe(true);

    // second time within the year: no bailout
    player.c.money = -100;
    const again = processWeeklyFriendshipTick(player);
    expect(player.c.money).toBe(-100);
    expect(again.some((e) => 'message' in e && e.message.includes('covered you'))).toBe(false);
  });

  it('no bailout without a best friend', () => {
    vi.spyOn(Math, 'random').mockReturnValue(0.99);
    const player = makePlayer();
    addFriend(player, 70, 'close');
    player.c.money = -150;
    processWeeklyFriendshipTick(player);
    expect(player.c.money).toBe(-150);
  });

  it('weekly beat stamps lastFriendInteractionDay on positive beats', () => {
    const player = makePlayer();
    const f = addFriend(player, 70, 'close');
    f.lastFriendInteractionDay = player.c.ageDays - 10;
    // random: 0.0 -> beat fires, picks first friend, first CLOSE beat (positive)
    vi.spyOn(Math, 'random').mockReturnValue(0.0);
    const events = processWeeklyFriendshipTick(player);
    expect(events.some((e) => 'message' in e)).toBe(true);
    expect(f.lastFriendInteractionDay).toBe(Math.floor(player.c.ageDays));
    expect(f.affinity).toBeGreaterThan(70);
  });
});
```

- [ ] **Step 2: Run, fix any drift between test and implementation, pass**

Run: `cd server && npx vitest run tests/services/friendship-manager.test.ts`
Expected: PASS. If the happiness/stress math differs, the TEST has the intended semantics — fix the implementation.

- [ ] **Step 3: Commit**

```bash
git add tests/services/friendship-manager.test.ts
git commit -m "test(friendship): pin drift, fade, passives, and bailout behavior"
```

---

### Task 4: Wire the tick into BOTH loops + restrict T9 beats to family

**Files:**
- Modify: `server/src/events/relationships/randomEvents.ts` (FRIEND_TAGS → family only)
- Modify: `server/src/game/PlayerSession.ts` (~line 596)
- Modify: `server/src/game/engine/GameEngine.ts` (~line 955)
- Test: `server/tests/game/friend-events.test.ts` (update), `server/tests/services/friendship-wiring.test.ts` (create)

- [ ] **Step 1: Restrict T9's beats to family**

In `server/src/events/relationships/randomEvents.ts`, replace the `FRIEND_TAGS` definition (~line 318):

```typescript
const FRIEND_TAGS = new Set([
  'friend', 'best friend', 'bestfriend', 'bff', 'family', 'child', 'sibling', 'parent',
]);
```

with:

```typescript
// Family/children only. Friend beats moved to the Friendship V2 manager
// (services/relationships/friendship_manager.ts), which runs tier-aware beats
// alongside drift/payoffs. Keeping friends here too would double-beat them.
const FRIEND_TAGS = new Set(['family', 'child', 'sibling', 'parent']);
```

And update the `processWeeklyFriendEvents` doc comment's first line from "for a non-romantic relationship (friend/family/child)" to "for a FAMILY relationship (family/child/sibling/parent)".

- [ ] **Step 2: Wire online loop**

In `server/src/game/PlayerSession.ts`, add the import next to the existing relationships import (line 14):

```typescript
import { processWeeklyFriendshipTick } from '../services/relationships/friendship_manager.js';
```

Then in the weekly block (~line 596), change:

```typescript
        const relationshipEvents = [
          ...processWeeklyRelationshipEvents(this.player),
          ...processWeeklyFriendEvents(this.player), // friend/family beats too
        ];
```

to:

```typescript
        const relationshipEvents = [
          ...processWeeklyRelationshipEvents(this.player),
          ...processWeeklyFriendEvents(this.player), // family beats (friends moved to V2)
          ...processWeeklyFriendshipTick(this.player), // Friendship V2: tiers/drift/payoffs/beats
        ];
```

- [ ] **Step 3: Wire offline loop (mirror)**

In `server/src/game/engine/GameEngine.ts`, add the import near the `processWeeklyFriendEvents` import (line 19):

```typescript
import { processWeeklyFriendshipTick } from '../../services/relationships/friendship_manager.js';
```

Then at ~line 953, change:

```typescript
    const relationshipEvents = [
      ...processWeeklyRelationshipEvents(player),
      ...processWeeklyFriendEvents(player),
    ];
```

to:

```typescript
    const relationshipEvents = [
      ...processWeeklyRelationshipEvents(player),
      ...processWeeklyFriendEvents(player), // family beats (friends moved to V2)
      ...processWeeklyFriendshipTick(player), // Friendship V2: tiers/drift/payoffs/beats
    ];
```

- [ ] **Step 4: Write the wiring regression test**

```typescript
// server/tests/services/friendship-wiring.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

/**
 * Online/offline parity guard: the Friendship V2 weekly tick must be invoked
 * from BOTH loops (this is the project's #1 recurring bug class — a fix landing
 * in one loop only). Source-level check, mirroring how the loops are mirrored.
 */
describe('friendship tick wiring', () => {
  const read = (p: string) => readFileSync(resolve(__dirname, '../../', p), 'utf8');

  it('PlayerSession (online) calls processWeeklyFriendshipTick', () => {
    expect(read('src/game/PlayerSession.ts')).toContain('processWeeklyFriendshipTick(this.player)');
  });

  it('GameEngine (offline) calls processWeeklyFriendshipTick', () => {
    expect(read('src/game/engine/GameEngine.ts')).toContain('processWeeklyFriendshipTick(player)');
  });

  it('T9 family beats no longer cover friend tags (no double-beating)', () => {
    const src = read('src/events/relationships/randomEvents.ts');
    const tagBlock = src.slice(src.indexOf('const FRIEND_TAGS'), src.indexOf('const FRIEND_TAGS') + 300);
    expect(tagBlock).not.toContain("'friend',");
    expect(tagBlock).toContain("'family'");
  });
});
```

- [ ] **Step 5: Update the existing T9 test**

Run: `cd server && npx vitest run tests/game/friend-events.test.ts`
If it fails because it asserted beats fire for `'friend'`-tagged NPCs: update those fixtures to use a family tag (`'sibling'`) instead — the T9 function now covers family only. Keep its other assertions intact.

- [ ] **Step 6: Run targeted tests, then the full suite**

```bash
cd server && npx vitest run tests/services/ tests/game/friend-events.test.ts
npx tsc --noEmit && npx vitest run
```
Expected: full suite green (1763 + new tests).

- [ ] **Step 7: Commit**

```bash
git add src/events/relationships/randomEvents.ts src/game/PlayerSession.ts src/game/engine/GameEngine.ts tests/
git commit -m "feat(friendship): wire weekly tick into both loops; T9 beats now family-only"
```

---

# PHASE 2 — v2 engine NPC binding (the keystone)

### Task 5: Types + selector eligibility + interpolation

**Files:**
- Modify: `server/src/events/v2/types.ts`
- Modify: `server/src/events/v2/engine/selector.ts`
- Test: `server/tests/events-v2/engine.target-binding.test.ts` (create)

- [ ] **Step 1: Write the failing tests**

```typescript
// server/tests/events-v2/engine.target-binding.test.ts
import { describe, it, expect } from 'vitest';
import type { EventDefinition, EventPlayerContext } from '../../src/events/v2/types.js';
import { isEventEligible, interpolateTarget } from '../../src/events/v2/engine/selector.js';

const basePlayer = (): EventPlayerContext =>
  ({ userId: 'u1', c: { ageYears: 20, ageDays: 7300 }, r: [] }) as unknown as EventPlayerContext;

describe('selectTarget eligibility', () => {
  const def = (selectTarget: EventDefinition['selectTarget']): EventDefinition => ({
    id: 'bind-test',
    category: 'friendship',
    prompt: 'Hello {target}',
    choices: [{ choiceId: 'ok', text: 'ok' }],
    repeatable: true,
    selectTarget,
  });

  it('event with selectTarget returning null is ineligible', () => {
    expect(isEventEligible(def(() => null), basePlayer(), 100)).toBe(false);
  });

  it('event with selectTarget returning a target is eligible', () => {
    expect(isEventEligible(def(() => ({ personId: 'p1', name: 'Ana' })), basePlayer(), 100)).toBe(true);
  });
});

describe('interpolateTarget', () => {
  it('replaces every {target} occurrence', () => {
    expect(interpolateTarget('Hey {target}, {target} called.', 'Ana')).toBe('Hey Ana, Ana called.');
  });
  it('no-ops without a name', () => {
    expect(interpolateTarget('Hey {target}', undefined)).toBe('Hey {target}');
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `cd server && npx vitest run tests/events-v2/engine.target-binding.test.ts`
Expected: FAIL (`selectTarget` not in EventDefinition type; `interpolateTarget` not exported).

- [ ] **Step 3: Extend types.ts**

In `server/src/events/v2/types.ts`:

(a) After the `DynamicTextFn` type (~line 12), add:

```typescript
/**
 * A specific NPC an event instance is bound to. Returned by an
 * EventDefinition's `selectTarget` at fire time, persisted in the instance
 * context (`targetPersonId`/`targetName`), interpolated into event text via the
 * `{target}` placeholder, and used to resolve `effects.target` against a real
 * person in player.r. Reusable for friend / family / coworker events.
 */
export interface BoundTarget {
  personId: string;
  name: string;
}
```

(b) In `EventEffects` (after the `relationships?` member), add:

```typescript
  /**
   * Affinity change against the event instance's BOUND target (see
   * EventDefinition.selectTarget). Unlike `relationships` (which needs a
   * concrete personId at definition time — impossible for catalog events that
   * pick a target per firing), this is resolved at apply time from the
   * instance's `targetPersonId`. A positive delta also stamps the target's
   * `lastFriendInteractionDay` (Friendship V2 neglect tracking).
   */
  target?: {
    affinityDelta: number;
  };
```

(c) In `EventDefinition` (after `isEligible?`), add:

```typescript
  /**
   * Bind this event to a specific NPC. Called during eligibility (a null
   * return makes the event ineligible this tick) and once more at fire time
   * to pick the bound target. The chosen `personId`/`name` are stored in the
   * instance context; `{target}` in prompt/choice/resolution text is replaced
   * with the name, and `effects.target` resolves against the personId.
   * Selection may be random among eligible NPCs — the two calls need not
   * agree, both picks are valid targets.
   */
  selectTarget?: (player: EventPlayerContext) => BoundTarget | null;
```

- [ ] **Step 4: Extend selector.ts**

In `server/src/events/v2/engine/selector.ts`:

(a) Add after `resolveText` (~line 17):

```typescript
/**
 * Replace every `{target}` placeholder with the bound NPC's name. No-op when
 * no name is available (unbound events never contain the placeholder).
 */
export function interpolateTarget(text: string, targetName: string | undefined): string {
  if (!targetName) return text;
  return text.split('{target}').join(targetName);
}
```

(b) In `isEventEligible`, after the `definition.isEligible` check (~line 120), add:

```typescript
  // Target binding gate: an event that binds to an NPC is only eligible while
  // an eligible target exists (selectTarget contract: null = no target).
  if (definition.selectTarget && definition.selectTarget(player) === null) {
    return false;
  }
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd server && npx vitest run tests/events-v2/engine.target-binding.test.ts`
Expected: PASS.

- [ ] **Step 6: Typecheck and commit**

```bash
cd server && npx tsc --noEmit
git add src/events/v2/types.ts src/events/v2/engine/selector.ts tests/events-v2/engine.target-binding.test.ts
git commit -m "feat(events-v2): NPC target binding — types, eligibility gate, {target} interpolation"
```

---

### Task 6: Effects resolution + engine binding + responder context

**Files:**
- Modify: `server/src/events/v2/engine/effects.ts`
- Modify: `server/src/events/v2/engine/EventEngine.ts`
- Modify: `server/src/events/v2/engine/respond.ts`
- Test: `server/tests/events-v2/engine.target-binding.test.ts` (extend)

- [ ] **Step 1: Write the failing tests (append to the Task 5 test file)**

```typescript
import { applyEventEffects } from '../../src/events/v2/engine/effects.js';

describe('effects.target resolution', () => {
  const playerWithFriend = () =>
    ({
      userId: 'u1',
      c: { ageYears: 20, ageDays: 7300 },
      r: [{ id: 'npc-1', firstname: 'Ana', affinity: 50, relationships: ['friend'] }],
    }) as any;

  it('applies target affinityDelta to the bound NPC and reports it by name', () => {
    const player = playerWithFriend();
    const applied = applyEventEffects(player, { target: { affinityDelta: 10 } }, 'npc-1');
    expect(player.r[0].affinity).toBe(60);
    expect(applied).toEqual([{ personId: 'npc-1', name: 'Ana', affinityDelta: 10 }]);
  });

  it('stamps lastFriendInteractionDay on a positive target delta', () => {
    const player = playerWithFriend();
    applyEventEffects(player, { target: { affinityDelta: 5 } }, 'npc-1');
    expect(player.r[0].lastFriendInteractionDay).toBe(7300);
  });

  it('does NOT stamp interaction day on a negative delta', () => {
    const player = playerWithFriend();
    applyEventEffects(player, { target: { affinityDelta: -10 } }, 'npc-1');
    expect(player.r[0].affinity).toBe(40);
    expect(player.r[0].lastFriendInteractionDay).toBeUndefined();
  });

  it('no-ops gracefully when the bound NPC is missing (e.g. died mid-event)', () => {
    const player = playerWithFriend();
    const applied = applyEventEffects(player, { target: { affinityDelta: 10 } }, 'npc-gone');
    expect(applied).toEqual([]);
  });

  it('no-ops target effects when no targetPersonId is supplied', () => {
    const player = playerWithFriend();
    const applied = applyEventEffects(player, { target: { affinityDelta: 10 } });
    expect(player.r[0].affinity).toBe(50);
    expect(applied).toEqual([]);
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `cd server && npx vitest run tests/events-v2/engine.target-binding.test.ts`
Expected: FAIL (applyEventEffects has no third parameter / target ignored).

- [ ] **Step 3: Extend effects.ts**

In `server/src/events/v2/engine/effects.ts`:

(a) Change the signature and early-out of `applyEventEffects`:

```typescript
export function applyEventEffects(
  player: StatPlayer,
  effects?: EventEffects,
  targetPersonId?: string
): ResolvedRelationshipEffect[] {
  if (!effects?.resources && !effects?.stats && !effects?.relationships && !effects?.target) {
    return [];
  }
```

(b) After the existing `effects.relationships` block (before the final `return applied;`), add:

```typescript
  // Bound-target effect: resolve the per-instance NPC binding (see
  // EventDefinition.selectTarget). Same clamp + reporting as `relationships`,
  // plus Friendship V2 interaction stamping on positive deltas so a warm
  // choice resets the neglect-drift clock.
  if (effects.target && targetPersonId && Number.isFinite(effects.target.affinityDelta) && effects.target.affinityDelta !== 0) {
    const relationships = Array.isArray(player.r) ? player.r : [];
    const target = relationships.find((person) => person?.id === targetPersonId);
    if (target) {
      const current = asNumber(target.affinity, 50);
      target.affinity = Math.max(-100, Math.min(100, current + effects.target.affinityDelta));
      if (effects.target.affinityDelta > 0) {
        const ageDays = asNumber((player.c as { ageDays?: unknown }).ageDays, 0);
        (target as { lastFriendInteractionDay?: number }).lastFriendInteractionDay = Math.floor(ageDays);
      }
      applied.push({
        personId: targetPersonId,
        name: relationshipDisplayName(target),
        affinityDelta: effects.target.affinityDelta,
      });
    }
  }
```

- [ ] **Step 4: Bind the target in EventEngine.promptNext**

In `server/src/events/v2/engine/EventEngine.ts`:

(a) Update the selector import (line 11) to:

```typescript
import { interpolateTarget, resolveText, selectNextEligibleEvent } from './selector.js';
```

(b) In `promptNext`, after `recordEventFired(player, definition.id, currentDay);` (~line 104) and before the `createEventInstance` call, add:

```typescript
    // Bind a specific NPC for target-bound events. selectTarget already passed
    // the eligibility gate; a defensive null here (e.g. the NPC died this tick)
    // simply skips this prompt.
    const boundTarget = definition.selectTarget ? definition.selectTarget(player) : null;
    if (definition.selectTarget && !boundTarget) {
      return null;
    }
```

(c) Replace the `createEventInstance` call's `prompt`, `choices`, and `context` values:

```typescript
      prompt: interpolateTarget(
        resolveText(definition.promptFn, definition.prompt, player),
        boundTarget?.name
      ),
      choices: definition.choices.map((choice) => ({
        choiceId: choice.choiceId,
        text: interpolateTarget(choice.text, boundTarget?.name),
        energyCost: choice.energyCost,
        moneyCost: effectiveMoneyCost(choice),
        diamondCost: choice.diamondCost,
      })),
      context: {
        category: definition.category,
        ...(boundTarget ? { targetPersonId: boundTarget.personId, targetName: boundTarget.name } : {}),
      },
```

(d) In the passive branch, pass the binding through. Change `applyPassiveEffects(player, passiveChoice);` to:

```typescript
      applyPassiveEffects(player, passiveChoice, boundTarget?.personId);
```

and change the passive resolution text to interpolate:

```typescript
      const passiveResolutionText = interpolateTarget(
        resolveText(
          passiveChoice.resolutionTextFn,
          passiveChoice.resolutionText ?? passiveChoice.text,
          player
        ),
        boundTarget?.name
      );
```

(e) Update `applyPassiveEffects` (bottom of the file) to accept and forward the binding — change its signature and final call:

```typescript
function applyPassiveEffects(
  player: EventPlayerContext,
  choice: {
    energyCost?: number;
    moneyCost?: number;
    diamondCost?: number;
    effects?: EventEffects;
  },
  targetPersonId?: string
): void {
  const resourceEffects = {
    energy: choice.energyCost ? -choice.energyCost : 0,
    money: choice.moneyCost ? -choice.moneyCost : 0,
    diamonds: choice.diamondCost ? -choice.diamondCost : 0,
    ...choice.effects?.resources,
  };

  applyEventEffects(
    player as { c: Record<string, unknown> },
    {
      ...choice.effects,
      resources: resourceEffects,
    },
    targetPersonId
  );
}
```

Add `EventEffects` to the types import at the top of EventEngine.ts:

```typescript
import type {
  EventEffects,
  EventPlayerContext,
  EventPromptEnvelope,
  EventResolvedEnvelope,
} from '../types.js';
```

- [ ] **Step 5: Thread the context through the responder**

In `server/src/events/v2/engine/respond.ts`:

(a) Widen the store interface's pending shape (the DB store already returns `context` — see `database/eventInstances.ts:toRecord`; the interface just doesn't declare it):

```typescript
interface PendingEventInstance {
  instanceId: string;
  playerId: string;
  eventId: string;
  status: 'pending' | 'answered' | 'resolved' | 'cancelled';
  /** Instance context written at fire time (category + target binding). */
  context?: Record<string, unknown> | null;
}
```

(b) Update the selector import (line 11):

```typescript
import { interpolateTarget, resolveText } from './selector.js';
```

(c) In `respond`, after the affordability gate and before `answerEventInstance` (~line 117), add:

```typescript
    // Per-instance NPC binding written by EventEngine at fire time.
    const targetPersonId =
      typeof pendingInstance.context?.targetPersonId === 'string'
        ? pendingInstance.context.targetPersonId
        : undefined;
    const targetName =
      typeof pendingInstance.context?.targetName === 'string'
        ? pendingInstance.context.targetName
        : undefined;
```

(d) Change the effects + resolution lines:

```typescript
    const resolvedRelationships = applyChoiceEffects(player, choice, targetPersonId);
```

and:

```typescript
    const resolutionText = interpolateTarget(
      resolveText(choice.resolutionTextFn, choice.resolutionText ?? choice.text, player),
      targetName
    );
```

(e) Update `applyChoiceEffects` to forward the binding:

```typescript
function applyChoiceEffects(
  player: EventPlayerContext,
  choice: EventChoice,
  targetPersonId?: string
): ResolvedRelationshipEffect[] {
  const baseEffects = choice.effects ?? {};
  const resourceEffects = {
    energy: choice.energyCost ? -choice.energyCost : 0,
    money: choice.moneyCost ? -choice.moneyCost : 0,
    diamonds: choice.diamondCost ? -choice.diamondCost : 0,
    ...baseEffects.resources,
  };

  return applyEventEffects(
    player as { c: Record<string, unknown>; r?: Array<Record<string, unknown>> },
    {
      ...baseEffects,
      resources: resourceEffects,
    },
    targetPersonId
  );
}
```

- [ ] **Step 6: Add an end-to-end binding test (append to the same test file)**

```typescript
import { EventEngine } from '../../src/events/v2/engine/EventEngine.js';
import { EventResponder } from '../../src/events/v2/engine/respond.js';
import { EventRegistry } from '../../src/events/v2/registry.js';

describe('end-to-end target binding (fire -> respond)', () => {
  function makeStore() {
    const instances: any[] = [];
    return {
      instances,
      async getPendingEventInstances(playerId: string) {
        return instances.filter((i) => i.playerId === playerId && i.status === 'pending');
      },
      async createEventInstance(input: any) {
        const record = { ...input, status: 'pending' };
        instances.push(record);
        return record;
      },
      async answerEventInstance(instanceId: string, choiceId: string) {
        const inst = instances.find((i) => i.instanceId === instanceId);
        if (!inst) return false;
        inst.status = 'answered';
        inst.selectedChoiceId = choiceId;
        return true;
      },
      async resolveEventInstance(instanceId: string) {
        const inst = instances.find((i) => i.instanceId === instanceId);
        if (!inst) return false;
        inst.status = 'resolved';
        return true;
      },
    };
  }

  const boundDef: EventDefinition = {
    id: 'bound-coffee',
    category: 'friendship',
    prompt: '{target} invites you to coffee.',
    repeatable: true,
    selectTarget: (player) => {
      const friend = (player as any).r?.find((p: any) =>
        (p.relationships ?? []).includes('friend')
      );
      return friend ? { personId: friend.id, name: friend.firstname } : null;
    },
    choices: [
      {
        choiceId: 'go',
        text: 'Grab coffee with {target}',
        resolutionText: 'You and {target} caught up over coffee.',
        effects: { target: { affinityDelta: 5 } },
      },
    ],
  };

  it('binds, interpolates, and applies the target effect on respond', async () => {
    const store = makeStore();
    const registry = new EventRegistry([boundDef]);
    const engine = new EventEngine(registry, store as any);
    const responder = new EventResponder(registry, store as any);
    const player: any = {
      userId: 'u1',
      c: { ageYears: 20, ageDays: 7300, money: 100 },
      r: [{ id: 'npc-1', firstname: 'Ana', affinity: 50, relationships: ['friend'] }],
      askedQuestions: new Set<string>(),
    };

    const prompt = await engine.promptNext(player);
    expect(prompt?.type).toBe('event_prompt');
    expect((prompt as any).prompt).toBe('Ana invites you to coffee.');
    expect((prompt as any).choices[0].text).toBe('Grab coffee with Ana');
    expect(store.instances[0].context).toMatchObject({
      targetPersonId: 'npc-1',
      targetName: 'Ana',
    });

    const resolved = await responder.respond(player, { eventId: 'bound-coffee', choiceId: 'go' });
    expect(resolved.type).toBe('event_resolved');
    expect((resolved as any).resolutionText).toBe('You and Ana caught up over coffee.');
    expect(player.r[0].affinity).toBe(55);
    expect((resolved as any).resolvedRelationships).toEqual([
      { personId: 'npc-1', name: 'Ana', affinityDelta: 5 },
    ]);
  });
});
```

Verified 2026-06-10: `EventRegistry`'s constructor takes `(definitions: EventDefinition[])` (`server/src/events/v2/registry.ts:6`), so the construction above works as written.

- [ ] **Step 7: Run the binding tests, then the full v2 suite**

```bash
cd server && npx vitest run tests/events-v2/
```
Expected: all events-v2 tests pass (existing + new).

- [ ] **Step 8: Typecheck and commit**

```bash
npx tsc --noEmit
git add src/events/v2/ tests/events-v2/engine.target-binding.test.ts
git commit -m "feat(events-v2): bind event instances to NPCs — context persistence, effects.target, text interpolation"
```

---

# PHASE 3 — Content + player action

### Task 7: Friendship v2 catalog (5 bound events)

**Files:**
- Create: `server/src/events/v2/catalog/friendship.ts`
- Modify: `server/src/events/v2/catalog/index.ts`
- Test: `server/tests/events-v2/catalog.friendship.test.ts` (create)

- [ ] **Step 1: Write the failing test**

```typescript
// server/tests/events-v2/catalog.friendship.test.ts
import { describe, it, expect } from 'vitest';
import { friendshipCatalog } from '../../src/events/v2/catalog/friendship.js';
import { eventCatalog } from '../../src/events/v2/catalog/index.js';

describe('friendship v2 catalog', () => {
  it('registers 5 friendship events in the live catalog', () => {
    expect(friendshipCatalog).toHaveLength(5);
    const ids = friendshipCatalog.map((d) => d.id);
    for (const id of ids) {
      expect(eventCatalog.some((d) => d.id === id)).toBe(true);
    }
  });

  it('every friendship event is repeatable with a cooldown and binds a target', () => {
    for (const def of friendshipCatalog) {
      expect(def.category).toBe('friendship');
      expect(def.repeatable).toBe(true);
      expect(def.cooldownDays ?? 0).toBeGreaterThan(0);
      expect(typeof def.selectTarget).toBe('function');
    }
  });

  it('selectTarget returns null with no friends and a friend when one qualifies', () => {
    const crisis = friendshipCatalog.find((d) => d.id === 'friendship-friend-in-crisis')!;
    const noFriends: any = { userId: 'u', c: { ageYears: 20 }, r: [] };
    expect(crisis.selectTarget!(noFriends)).toBeNull();

    const withFriend: any = {
      userId: 'u',
      c: { ageYears: 20 },
      r: [{ id: 'f1', firstname: 'Ana', status: 'alive', affinity: 50, relationships: ['friend'] }],
    };
    expect(crisis.selectTarget!(withFriend)).toEqual({ personId: 'f1', name: 'Ana' });
  });

  it('every choice references the target via effects.target (not hardcoded personIds)', () => {
    for (const def of friendshipCatalog) {
      for (const choice of def.choices) {
        expect(choice.effects?.relationships).toBeUndefined();
      }
    }
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `cd server && npx vitest run tests/events-v2/catalog.friendship.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Create the catalog**

Create `server/src/events/v2/catalog/friendship.ts`:

```typescript
/**
 * Friendship V2 catalog — the first NPC-BOUND v2 events. Each event binds to a
 * specific eligible friend (selectTarget), names them in the text ({target}),
 * and moves THAT friend's affinity via effects.target. Replaces the dead
 * function-based events/social/friendships.ts (which only the HeadlessGame
 * harness ever parsed) with live, choiceful content.
 *
 * Stakes ported from the highest-value dead events: crisis support, betrayal,
 * lending money, helping a move, and the birthday-gift tradeoff.
 */
import type { BoundTarget, EventDefinition, EventPlayerContext } from '../types.js';

interface FriendRecord {
  id?: string;
  firstname?: string;
  status?: string;
  affinity?: number;
  relationships?: string[];
}

const FRIEND_TAGS = new Set(['friend', 'best friend', 'bestfriend', 'bff']);

/**
 * Pick a RANDOM living friend within an affinity band. Random (not first)
 * so repeatable events spread across the circle rather than always hitting
 * the same NPC.
 */
function pickFriend(
  player: EventPlayerContext,
  opts: { minAffinity?: number; maxAffinity?: number } = {}
): BoundTarget | null {
  const roster = (player as { r?: FriendRecord[] }).r;
  const candidates = (Array.isArray(roster) ? roster : []).filter((p) => {
    if (!p?.id || p.status !== 'alive') return false;
    if (!(p.relationships ?? []).some((t) => FRIEND_TAGS.has(String(t).toLowerCase()))) return false;
    const affinity = typeof p.affinity === 'number' ? p.affinity : 50;
    if (opts.minAffinity !== undefined && affinity < opts.minAffinity) return false;
    if (opts.maxAffinity !== undefined && affinity > opts.maxAffinity) return false;
    return true;
  });
  if (candidates.length === 0) return null;
  const pick = candidates[Math.floor(Math.random() * candidates.length)];
  return { personId: pick.id as string, name: pick.firstname || 'your friend' };
}

export const friendshipCatalog: EventDefinition[] = [
  {
    id: 'friendship-friend-in-crisis',
    category: 'friendship',
    prompt:
      '{target} calls you late at night, voice shaking. Things have fallen apart for them — they need someone right now.',
    minAge: 14,
    repeatable: true,
    cooldownDays: 180,
    weight: 2,
    selectTarget: (player) => pickFriend(player, { minAffinity: 30 }),
    choices: [
      {
        choiceId: 'show-up',
        text: 'Drop everything and go over with food ($30)',
        resolutionText:
          'You showed up with takeout and stayed until sunrise. {target} will never forget that you came.',
        effects: {
          resources: { money: -30, energy: -20 },
          stats: { happiness: 3, stress: 5 },
          target: { affinityDelta: 15 },
        },
      },
      {
        choiceId: 'long-call',
        text: 'Stay on the phone as long as they need',
        resolutionText: 'You talked {target} through the worst of it. It mattered.',
        effects: {
          resources: { energy: -10 },
          target: { affinityDelta: 8 },
        },
      },
      {
        choiceId: 'text-support',
        text: 'Send a supportive text — you can talk tomorrow',
        resolutionText: 'You sent what comfort you could. {target} understood, mostly.',
        effects: { target: { affinityDelta: 3 } },
      },
      {
        choiceId: 'space',
        text: "You're exhausted. Let it go to voicemail",
        resolutionText:
          "{target} never brought it up again. But something between you cooled that night.",
        effects: {
          stats: { happiness: -2 },
          target: { affinityDelta: -8 },
        },
      },
    ],
  },
  {
    id: 'friendship-betrayal',
    category: 'friendship',
    prompt:
      'You find out {target} shared something you told them in confidence. People know things only {target} knew.',
    minAge: 14,
    repeatable: true,
    cooldownDays: 365,
    selectTarget: (player) => pickFriend(player, { minAffinity: 30, maxAffinity: 70 }),
    choices: [
      {
        choiceId: 'confront',
        text: 'Confront them directly',
        resolutionText:
          '{target} went pale, then owned it. The honesty hurt both of you — but at least it was honest.',
        effects: {
          stats: { stress: 10 },
          target: { affinityDelta: -12 },
        },
        setFlags: ['friendship_betrayal_confronted'],
      },
      {
        choiceId: 'forgive',
        text: 'Let it go — everyone slips',
        resolutionText:
          "You decided the friendship was worth more than the grudge. Still, you'll be careful what you tell {target} now.",
        effects: {
          stats: { happiness: -3 },
          target: { affinityDelta: -6 },
        },
      },
      {
        choiceId: 'distance',
        text: 'Say nothing, but quietly pull back',
        resolutionText:
          '{target} noticed the distance eventually. Neither of you ever said why.',
        effects: {
          stats: { happiness: -3 },
          target: { affinityDelta: -10 },
        },
      },
    ],
  },
  {
    id: 'friendship-lend-money',
    category: 'friendship',
    prompt:
      "{target} asks to borrow money — they're short this month and out of options. They look embarrassed asking.",
    minAge: 16,
    repeatable: true,
    cooldownDays: 240,
    isEligible: (player) => {
      const money = (player.c as { money?: number }).money;
      return typeof money === 'number' && money >= 150;
    },
    selectTarget: (player) => pickFriend(player, { minAffinity: 40 }),
    choices: [
      {
        choiceId: 'lend-full',
        text: 'Lend them $200, no questions',
        resolutionText:
          '{target} took it with shaking hands. "I owe you." Some of it may come back; the trust already did.',
        effects: {
          resources: { money: -200 },
          target: { affinityDelta: 10 },
        },
      },
      {
        choiceId: 'lend-half',
        text: 'Lend $100 — what you can spare',
        resolutionText: '{target} was grateful for anything. It took the edge off their month.',
        effects: {
          resources: { money: -100 },
          target: { affinityDelta: 5 },
        },
      },
      {
        choiceId: 'decline',
        text: "Apologize — you can't right now",
        resolutionText:
          '{target} said it was fine. The next few hangouts felt a little stiff.',
        effects: { target: { affinityDelta: -6 } },
      },
    ],
  },
  {
    id: 'friendship-help-moving',
    category: 'friendship',
    prompt:
      "{target} is moving apartments this weekend and asks for your help hauling boxes. It's a big job.",
    minAge: 18,
    repeatable: true,
    cooldownDays: 240,
    isEligible: (player) => {
      const energy = (player.c as { energy?: number }).energy;
      return typeof energy === 'number' && energy >= 40;
    },
    selectTarget: (player) => pickFriend(player, { minAffinity: 40 }),
    choices: [
      {
        choiceId: 'help',
        text: 'Show up early with coffee and work all day',
        resolutionText:
          'Sore arms, pizza on moving boxes, and {target} declaring you a saint. Worth it.',
        effects: {
          resources: { energy: -30 },
          stats: { happiness: 2 },
          target: { affinityDelta: 10 },
        },
      },
      {
        choiceId: 'decline',
        text: 'Beg off — your weekend is packed',
        resolutionText:
          "{target} hired movers in the end. They didn't hold it against you. Mostly.",
        effects: { target: { affinityDelta: -6 } },
      },
    ],
  },
  {
    id: 'friendship-birthday-gift',
    category: 'friendship',
    prompt: "It's {target}'s birthday this week. What kind of friend are you going to be?",
    minAge: 10,
    repeatable: true,
    cooldownDays: 120,
    selectTarget: (player) => pickFriend(player),
    choices: [
      {
        choiceId: 'big-gift',
        text: 'Get them something they actually want ($60)',
        resolutionText:
          "{target} couldn't believe you remembered. Best gift they got all year.",
        effects: {
          resources: { money: -60 },
          stats: { happiness: 2 },
          target: { affinityDelta: 10 },
        },
      },
      {
        choiceId: 'small-gift',
        text: 'A small, thoughtful gift ($20)',
        resolutionText: 'Small but right. {target} loved that you got THEM, not just a gift.',
        effects: {
          resources: { money: -20 },
          target: { affinityDelta: 5 },
        },
      },
      {
        choiceId: 'just-call',
        text: 'Call and sing them happy birthday, badly',
        resolutionText: '{target} laughed for a full minute. Free, and somehow perfect.',
        effects: { target: { affinityDelta: 2 } },
      },
      {
        choiceId: 'forget',
        text: '(Do nothing — it slips your mind)',
        resolutionText: 'You remembered three days later. {target} said "no big deal" in the way that means it was.',
        effects: { target: { affinityDelta: -6 } },
      },
    ],
  },
];
```

- [ ] **Step 4: Register in the catalog index**

In `server/src/events/v2/catalog/index.ts`, add the import alongside the others:

```typescript
import { friendshipCatalog } from './friendship.js';
```

and add to the `eventCatalog` array (after `...familyArcCatalog,`):

```typescript
  ...friendshipCatalog,
```

Also re-export it with the other catalog exports if the file re-exports catalogs (match the existing pattern in that file).

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd server && npx vitest run tests/events-v2/`
Expected: PASS, including any existing catalog-shape tests (if a catalog validation test asserts unique ids / schema, the new events must satisfy it).

- [ ] **Step 6: Typecheck and commit**

```bash
cd server && npx tsc --noEmit
git add src/events/v2/catalog/ tests/events-v2/catalog.friendship.test.ts
git commit -m "feat(friendship): 5 NPC-bound v2 events — crisis, betrayal, lending, moving, birthday"
```

---

### Task 8: `hangOut` WebSocket command

**Files:**
- Create: `server/src/handlers/friendship.ts`
- Modify: `server/src/handlers/index.ts`
- Modify: `server/src/contracts/websocket-commands.ts`
- Modify: `server/tests/contracts/fixtures/commands.json`
- Test: `server/tests/handlers/hangout.test.ts` (create)

- [ ] **Step 1: Write the failing tests**

First inspect an existing handler test for the session mock shape: `ls server/tests/handlers/` and read one (e.g. whichever tests `setSpendingHabits`/`retire`, or any handler test) — reuse its session/player factory if one exists. If none fits, use this self-contained mock:

```typescript
// server/tests/handlers/hangout.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { handleHangOut } from '../../src/handlers/friendship.js';
import { Player } from '../../src/models/Player.js';
import { Person } from '../../src/models/Person.js';

function makeSession() {
  const player = new Player({ id: 'hangout-test' } as any);
  player.c = new Person({ firstname: 'Hero', sex: 'female' } as any);
  player.c.ageDays = 7300;
  player.c.ageYears = 20;
  player.c.energy = 80;
  player.c.happiness = 50;
  const friend = new Person({ firstname: 'Ana', sex: 'female' } as any);
  friend.id = 'friend-1';
  friend.status = 'alive';
  friend.relationships = ['friend'];
  friend.affinity = 50;
  player.r = [friend];

  const sent: any[] = [];
  const session = {
    player,
    sent,
    send(msg: unknown) {
      sent.push(msg);
    },
    sendPlayerObject() {
      sent.push({ type: 'playerObject' });
    },
    async savePlayer() {},
  };
  return { session: session as any, player, friend, sent };
}

describe('handleHangOut', () => {
  it('costs energy, raises affinity, stamps interaction days, acks', async () => {
    const { session, player, friend, sent } = makeSession();
    await handleHangOut({ personId: 'friend-1' }, session);

    expect(player.c.energy).toBe(65);
    expect(friend.affinity).toBe(56);
    expect(player.c.happiness).toBe(53);
    expect(friend.lastFriendInteractionDay).toBe(7300);
    expect(friend.lastHangOutDay).toBe(7300);

    const ack = sent.find((m) => m.type === 'hangOutResult');
    expect(ack).toMatchObject({ success: true, personId: 'friend-1', affinity: 56 });
    expect(sent.some((m) => m.type === 'playerObject')).toBe(true);
  });

  it('rejects an unknown person', async () => {
    const { session, sent } = makeSession();
    await handleHangOut({ personId: 'nobody' }, session);
    expect(sent[0]).toMatchObject({ type: 'error' });
  });

  it('rejects a non-friend NPC', async () => {
    const { session, friend, sent } = makeSession();
    friend.relationships = ['coworker'];
    await handleHangOut({ personId: 'friend-1' }, session);
    expect(sent[0]).toMatchObject({ type: 'error' });
  });

  it('rejects when too tired (energy < 15)', async () => {
    const { session, player, sent } = makeSession();
    player.c.energy = 10;
    await handleHangOut({ personId: 'friend-1' }, session);
    expect(sent[0]).toMatchObject({ type: 'error' });
  });

  it('enforces the 3-day per-NPC cooldown', async () => {
    const { session, player, friend, sent } = makeSession();
    friend.lastHangOutDay = player.c.ageDays - 1;
    await handleHangOut({ personId: 'friend-1' }, session);
    expect(sent[0]).toMatchObject({ type: 'error' });
    expect(friend.affinity).toBe(50);
  });
});
```

- [ ] **Step 2: Run to verify failure**

Run: `cd server && npx vitest run tests/handlers/hangout.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Implement the handler**

Create `server/src/handlers/friendship.ts`:

```typescript
/**
 * Friendship V2 player actions. `hangOut` is the direct-invest lever: the
 * player spends energy to actively grow a SPECIFIC friendship instead of
 * waiting on ambient beats. Pairs with the iOS "Hang Out" button on the NPC
 * profile (agent-native parity: one WS command, both UIs).
 */
import type { PlayerSession } from '../game/PlayerSession.js';
import { isFriendNpc, recordFriendInteraction } from '../services/relationships/friendship_manager.js';
import { clamp } from '../utils/statUtils.js';

export const HANGOUT_ENERGY_COST = 15;
export const HANGOUT_AFFINITY_GAIN = 6;
export const HANGOUT_HAPPINESS_GAIN = 3;
export const HANGOUT_COOLDOWN_DAYS = 3;

export async function handleHangOut(payload: unknown, session: PlayerSession): Promise<void> {
  const personId =
    payload && typeof payload === 'object'
      ? String((payload as { personId?: unknown }).personId ?? '')
      : '';
  if (!personId) {
    session.send({ type: 'error', message: 'hangOut requires a personId.' });
    return;
  }

  const player = session.player;
  const person = (player.r ?? []).find((p) => p.id === personId);
  if (!person) {
    session.send({ type: 'error', message: 'That person is not in your life.' });
    return;
  }
  if (!isFriendNpc(person)) {
    session.send({ type: 'error', message: 'You can only hang out with friends.' });
    return;
  }

  const today = Math.floor(player.c.ageDays ?? 0);
  if (
    person.lastHangOutDay !== undefined &&
    today - person.lastHangOutDay < HANGOUT_COOLDOWN_DAYS
  ) {
    session.send({
      type: 'error',
      message: `You just spent time with ${person.firstname}. Give it a few days.`,
    });
    return;
  }

  if ((player.c.energy ?? 0) < HANGOUT_ENERGY_COST) {
    session.send({ type: 'error', message: 'Too tired to hang out right now.' });
    return;
  }

  player.c.energy = clamp((player.c.energy ?? 0) - HANGOUT_ENERGY_COST, 0, 100);
  player.c.happiness = clamp((player.c.happiness ?? 50) + HANGOUT_HAPPINESS_GAIN, 0, 100);
  person.affinity = clamp((person.affinity ?? 50) + HANGOUT_AFFINITY_GAIN, -100, 100);
  recordFriendInteraction(player, person);
  person.lastHangOutDay = today;

  await session.savePlayer();
  session.sendPlayerObject();
  session.send({
    type: 'hangOutResult',
    success: true,
    personId,
    name: person.firstname,
    affinity: person.affinity,
    friendshipTier: person.friendshipTier ?? null,
  });
}
```

- [ ] **Step 4: Register the command**

In `server/src/handlers/index.ts`, add to the imports:

```typescript
import { handleHangOut } from './friendship.js';
```

and in `COMMAND_REGISTRY` (after the `'retire': handleRetire,` line):

```typescript
  'hangOut': handleHangOut,
```

- [ ] **Step 5: Add the contract entry**

In `server/src/contracts/websocket-commands.ts`, after the `retire:` entry (~line 316), add:

```typescript
  hangOut: {
    direction: 'client-to-server',
    envelope: 'message-field',
    payloads: [
      {
        kind: 'object',
        fields: {
          personId: { type: 'string' },
        },
      },
    ],
    idKeys: ['personId'],
    responses: [
      {
        type: 'hangOutResult',
        fields: { success: 'boolean', personId: 'string', name: 'string', affinity: 'number' },
      },
      { type: 'playerObject' },
      { type: 'error' },
    ],
    clients: ['ios', 'android'],
  },
```

(Match the exact field-descriptor shape used by neighboring entries — if `fields` values are written as `{ type: 'string', optional: true }` objects vs bare strings, copy the local convention.)

- [ ] **Step 6: Add the fixture**

In `server/tests/contracts/fixtures/commands.json`, after the `retire` line (~line 75), add:

```json
{ "command": "hangOut", "variant": "object-personId", "envelope": { "type": "hangOut", "message": { "personId": "friend-1" } }, "setup": "default", "invokeHandler": false },
```

(Respect JSON array comma placement relative to the surrounding lines.)

- [ ] **Step 7: Run handler + contract tests**

```bash
cd server && npx vitest run tests/handlers/hangout.test.ts tests/contracts/
```
Expected: PASS — the manifest test proves registry/contract/fixture all align.

- [ ] **Step 8: Typecheck and commit**

```bash
npx tsc --noEmit
git add src/handlers/ src/contracts/websocket-commands.ts tests/handlers/hangout.test.ts tests/contracts/fixtures/commands.json
git commit -m "feat(friendship): hangOut command — direct player investment in a friendship"
```

---

# PHASE 4 — Dead code cleanup

### Task 9: Delete friendships.ts and its registrations

**Files:**
- Delete: `server/src/events/social/friendships.ts` (1,139 lines)
- Modify: `server/src/events/social/index.ts`
- Modify: `server/src/events/index.ts`

Verified 2026-06-10: no test imports `friendshipEvents` / `friendshipClassEvents` / any friendships.ts symbol. The only references are the two index files. `classBasedEvents` itself has NO importers anywhere in `src/` (HeadlessGame imports only `allEvents`), so removing the friendship spread cannot break a consumer.

- [ ] **Step 1: Remove the registrations**

In `server/src/events/index.ts`:
- Delete line 77: `export { friendshipEvents, friendshipClassEvents } from './social/friendships.js';`
- Delete line 187: `import { friendshipEvents, friendshipClassEvents } from './social/friendships.js';`
- Delete line 224: `  ...friendshipEvents,`
- Delete line 238: `  ...friendshipClassEvents,`

(Line numbers shift after each deletion — match on content, not number.)

In `server/src/events/social/index.ts`:
- Delete the trailing block:

```typescript
// Friendship events (function-based and class-based)
export { friendshipEvents, friendshipClassEvents } from './friendships.js';
```

- In the header doc comment, delete the line `* - friendships: Friend hangout events (10-12 events)`.

- [ ] **Step 2: Delete the file**

```bash
cd server && git rm src/events/social/friendships.ts
```

- [ ] **Step 3: Verify nothing referenced it**

```bash
grep -rn "friendships" src/ tests/ --include="*.ts" | grep -v friendship_manager | grep -v "friendship-" | grep -v "friendshipTier" | grep -v "friendshipCatalog"
npx tsc --noEmit
npx vitest run
```
Expected: no dangling references; typecheck clean; full suite green.

- [ ] **Step 4: Commit**

```bash
git add -A src/events/
git commit -m "refactor(friendship): delete dead friendships.ts (1,139 lines) — superseded by V2 system + v2 catalog"
```

---

# PHASE 5 — iOS

### Task 10: Decode + display friendship tier

**Files:**
- Modify: `ios/lichunWebsocket/Core/Models/Person.swift`
- Modify: `ios/lichunWebsocket/Core/Services/ParsingHelpers.swift`
- Modify: `ios/lichunWebsocket/Features/Dating/Views/RelationshipsView.swift` (RelationshipCard)

- [ ] **Step 1: Add the model property**

In `ios/lichunWebsocket/Core/Models/Person.swift`, next to `@Published var affinity: Int = 0` (line 98), add:

```swift
    @Published var friendshipTier: String = ""  // "", "acquaintance", "friend", "close", "best"
```

- [ ] **Step 2: Parse it**

In `ios/lichunWebsocket/Core/Services/ParsingHelpers.swift`, next to the affinity parse (line 76):

```swift
    person.friendshipTier = personData["friendshipTier"] as? String ?? ""
```

- [ ] **Step 3: Show a tier badge on the relationship card**

In `ios/lichunWebsocket/Features/Dating/Views/RelationshipsView.swift`, inside `RelationshipCard`, where the relationship tags are rendered (the row showing the first 2 relationship strings), add a tier badge for friend NPCs. Add this helper to `RelationshipCard`:

```swift
    private var tierBadge: (label: String, color: Color)? {
        switch person.friendshipTier {
        case "best": return ("Best Friend", .yellow)
        case "close": return ("Close Friend", .orange)
        case "friend": return ("Friend", .green)
        default: return nil
        }
    }
```

and render it alongside the existing tag chips (match the local chip styling — font `.appCaptionBold` or whatever the neighboring tags use):

```swift
    if let badge = tierBadge {
        Text(badge.label)
            .font(.appCaptionBold)
            .padding(.horizontal, 6)
            .padding(.vertical, 2)
            .background(badge.color.opacity(0.2))
            .foregroundColor(badge.color)
            .cornerRadius(6)
    }
```

Adapt spacing/modifiers to the surrounding HStack — read the existing chip code first and mirror it exactly.

- [ ] **Step 4: Build check**

Run: `cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme lichunWebsocket -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | tail -5`
Expected: `BUILD SUCCEEDED`. (Use whatever simulator exists locally — `xcrun simctl list devices available | head` to pick one.)

- [ ] **Step 5: Commit**

```bash
git add ios/lichunWebsocket/Core/Models/Person.swift ios/lichunWebsocket/Core/Services/ParsingHelpers.swift ios/lichunWebsocket/Features/Dating/Views/RelationshipsView.swift
git commit -m "feat(ios/friendship): decode + badge friendship tier on relationship cards"
```

---

### Task 11: Wire the Hang Out button to the hangOut command

**Files:**
- Modify: `ios/lichunWebsocket/Shared/Utilities/Constants.swift` (WebSocketCommands)
- Modify: `ios/lichunWebsocket/Features/Character/Components/ActionsSection.swift`
- Modify: `ios/lichunWebsocket/Features/Character/Views/NPCProfileView.swift` (handleAction)

Today, "Hang Out" (shown at affinity ≥ 30 in `ActionsSection.swift:73`) opens a conversation (`cType: "activity"`). Keep that conversation flow on its existing "Start Interaction" path, and repoint the "Hang Out" action at the real `hangOut` command so it grants concrete progression.

- [ ] **Step 1: Add the command builder**

In `ios/lichunWebsocket/Shared/Utilities/Constants.swift`, inside the `WebSocketCommands` enum (near `applyForJob`, ~line 189):

```swift
    static func hangOut(personId: String) -> [String: Any] {
        envelope(type: "hangOut", payload: ["personId": personId])
    }
```

- [ ] **Step 2: Repoint the action**

In `ios/lichunWebsocket/Features/Character/Views/NPCProfileView.swift`, in `handleAction` (~line 211), change the `case "activity":` branch from sending the conversation-init message to:

```swift
    case "activity":
        webSocketService.sendMessage(message: WebSocketCommands.hangOut(personId: character.id))
```

(Drop the `showChatFullScreen = true` for this branch — the result arrives as a `hangOutResult` ack plus a refreshed `playerObject`; the card's affinity/tier update in place. If `handleAction` lives partly in `ActionsSection.swift`, apply the same change wherever the `"activity"` action id is dispatched.)

- [ ] **Step 3: Show feedback (minimal)**

If the WebSocket message router (`WebSocketService.swift` / its message-handling switch) surfaces unknown message types as no-ops, `hangOutResult` can be ignored safely for v1 — the refreshed `playerObject` updates affinity/tier displays. Verify there's no "unknown message type" crash path: grep `WebSocketService.swift` for the `default:` case in its type switch. If unknown types log-and-drop, done. Optional polish (only if trivial in the existing toast/banner infra): show "You hung out with {name} (+6)" on `hangOutResult`.

- [ ] **Step 4: Build + commit**

```bash
cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme lichunWebsocket -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | tail -5
git add ios/lichunWebsocket/Shared/Utilities/Constants.swift ios/lichunWebsocket/Features/Character/
git commit -m "feat(ios/friendship): Hang Out button sends the hangOut command"
```

---

# PHASE 6 — Verification

### Task 12: Full verification pass

- [ ] **Step 1: Server — typecheck + full suite**

```bash
cd server && npx tsc --noEmit && npx vitest run
```
Expected: 0 type errors; ALL tests green (baseline 1763 + ~25 new). Any pre-existing-failure claims must be checked against the baseline — at this worktree's base the suite was 100% green, so any failure is ours.

- [ ] **Step 2: Faithful long-life validation**

```bash
cd server && npm run metrics
```
Expected (~2 min): completes without crashing; eyeball the output for sane lifespans/economy. Friendship V2 adds weekly +/-: confirm no runaway happiness (passives are capped at +3/week) and money distribution is not distorted (bailout max $600/year).

- [ ] **Step 3: Headless playthrough smoke**

```bash
cd server && npx tsx scripts/playthrough.mts 2>&1 | tail -30
```
Expected: a life story runs to completion; friendship beats/tier messages may appear in the narrative.

- [ ] **Step 4: iOS build (final)**

```bash
cd ios && xcodebuild -project lichunWebsocket.xcodeproj -scheme lichunWebsocket -destination 'platform=iOS Simulator,name=iPhone 16' build 2>&1 | tail -3
```
Expected: BUILD SUCCEEDED.

- [ ] **Step 5: STOP — report to Craig before merge/deploy**

Do NOT merge to `main` or push. Summarize: tests, new behavior, and the deploy implication (push to main auto-deploys to production via webhook; server protocol additions are backward-compatible — old iOS clients ignore `friendshipTier` and never send `hangOut`). Craig decides merge + deploy timing, and whether the iOS changes ship in the same window.

---

## Self-Review Notes (already applied)

- **Spec coverage:** tiers (T2), milestones (T2 §4), drift/fade (T2/T3), tier-gated payoffs (T2 §5–6), NPC-bound v2 events with choices (T5–T7), player invest action (T8 server + T11 iOS), iOS UI (T10–T11), dead-code cleanup (T9). Option B fully covered.
- **Deliberate scope cuts:** multi-stage betrayal follow-up arc deferred (the `friendship_betrayal_confronted` flag is set, ready for a follow-up event later); no Android client work (Android app still pre-release; server contract marks `clients: ['ios','android']` for forward-compat).
- **Type consistency:** `BoundTarget`, `effects.target`, `selectTarget`, `friendshipTier`, `lastFriendInteractionDay`, `lastHangOutDay`, `lastFriendBailoutDay`, `processWeeklyFriendshipTick`, `recordFriendInteraction`, `isFriendNpc`, `computeTier`, `handleHangOut` — names match across all tasks.
- **Known adaptation points (verify-in-place, not placeholders):** contract field-descriptor convention (T8 step 5 note); iOS chip styling (T10 step 3); where the `"activity"` action id is dispatched (T11 step 2). (`EventRegistry` constructor verified — takes a definitions array.)
