# Node.js Backend Rewrite Design

**Date**: 2025-12-25
**Status**: Approved
**Approach**: Big bang rewrite (full replacement)

## Overview

Rewrite the BaoLife Python WebSocket backend to Node.js/TypeScript for improved performance and scalability while maintaining full protocol compatibility with the iOS client.

### Goals

- **3-10x performance improvement** over Python asyncio
- **Zero iOS changes** - identical WebSocket protocol
- **Type safety** via TypeScript strict mode
- **Same architecture patterns** - table-driven dispatcher, event registry, connection pooling

### Non-Goals

- Protocol changes
- New features (parity first)
- Database schema changes

---

## Technology Stack

| Component | Choice | Rationale |
|-----------|--------|-----------|
| Runtime | Node.js 20+ LTS | Stable, performant, good ecosystem |
| Language | TypeScript (strict) | Type safety, better DX |
| WebSocket | `ws` | Proven, fast, upgradeable to uWebSockets.js |
| Database | `mysql2/promise` | Async, prepared statements, pooling |
| Validation | `zod` | Runtime type validation for messages |
| AI | OpenAI Node SDK | Official SDK, same API |
| Testing | Vitest | Fast, TypeScript-native |

---

## Project Structure

```
server/
├── src/
│   ├── index.ts                    # Entry point
│   ├── config.ts                   # Environment configuration
│   │
│   ├── server/
│   │   ├── WebSocketServer.ts      # Server setup, connection handling
│   │   ├── ConnectionRegistry.ts   # O(1) user lookup by userId
│   │   ├── MessageDispatcher.ts    # Table-driven command routing
│   │   └── types.ts                # Server-related types
│   │
│   ├── game/
│   │   ├── GameLoop.ts             # 10ms tick loop logic
│   │   ├── PlayerSession.ts        # Per-player state + lifecycle
│   │   ├── BatchedUpdate.ts        # Efficient state batching
│   │   └── TimeManager.ts          # Game time, speed, seasons
│   │
│   ├── models/
│   │   ├── Player.ts               # Player class (mirrors Python)
│   │   ├── Person.ts               # Character class
│   │   ├── Activity.ts             # Activities, records, habits
│   │   ├── Conversation.ts         # Chat models
│   │   ├── MessageEvent.ts         # Event notifications
│   │   ├── Question.ts             # Choice events
│   │   └── index.ts                # Barrel export
│   │
│   ├── events/
│   │   ├── index.ts                # Event registry & getApplicableEvents()
│   │   ├── types.ts                # GameEvent, EventResult types
│   │   ├── tutorial/
│   │   ├── childhood/
│   │   ├── adolescence/
│   │   ├── adulthood/
│   │   ├── education/
│   │   ├── health/
│   │   ├── activities/
│   │   ├── holidays/
│   │   ├── conversations/
│   │   ├── dilemmas/
│   │   ├── negative/
│   │   ├── random/
│   │   └── school_year/
│   │
│   ├── handlers/
│   │   ├── index.ts                # COMMAND_REGISTRY export
│   │   ├── gameControl.ts          # start, stop, restart, speed
│   │   ├── character.ts            # characterSetup, deviceToken
│   │   ├── activities.ts           # extracurriculars, jobs
│   │   ├── conversations.ts        # messaging handlers
│   │   ├── purchases.ts            # monetization handlers
│   │   ├── retention.ts            # achievements, dailies, quests
│   │   ├── romance.ts              # dating, relationships
│   │   ├── data.ts                 # export, delete account
│   │   └── generic.ts              # Generic event handler
│   │
│   ├── services/
│   │   ├── ConversationService.ts  # AI-powered conversations
│   │   ├── MatchingService.ts      # Dating compatibility
│   │   ├── AchievementService.ts   # Achievement tracking
│   │   └── NotificationService.ts  # Push notifications
│   │
│   ├── monetization/
│   │   ├── energyRefills.ts
│   │   ├── timeSkips.ts
│   │   ├── diamondEconomy.ts
│   │   └── validation.ts
│   │
│   ├── retention/
│   │   ├── achievements.ts
│   │   ├── dailyRewards.ts
│   │   ├── dailyQuests.ts
│   │   ├── statistics.ts
│   │   └── tutorial.ts
│   │
│   ├── dating/
│   │   ├── bioGenerator.ts
│   │   ├── compatibility.ts
│   │   ├── matching.ts
│   │   ├── dateActivities.ts
│   │   └── relationshipEvents.ts
│   │
│   ├── database/
│   │   ├── pool.ts                 # Connection pool setup
│   │   ├── queries.ts              # Helper functions
│   │   ├── transactions.ts         # Transaction wrapper
│   │   └── migrations/             # Schema migrations (if needed)
│   │
│   └── utils/
│       ├── logger.ts
│       ├── rateLimiter.ts
│       ├── playerCache.ts
│       └── helpers.ts
│
├── tests/
│   ├── unit/
│   ├── integration/
│   └── fixtures/
│
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── Dockerfile
```

---

## Architecture Details

### 1. WebSocket Connection Lifecycle

```
Client Connect
  ↓
WebSocketServer.onConnection(ws)
  ├── Receive "init" message with userID
  ├── Register in ConnectionRegistry (Map<userId, PlayerSession>)
  └── Create PlayerSession
      ↓
    PlayerSession.start()
      ├── Load player from DB (or create new)
      ├── Initialize player state
      ├── Send playerObject to client
      └── Start game loop (setInterval 10ms)
          ├── tick() - advance game time
          └── sendBatchedUpdate() - on minute/hour boundaries

Client Message → ws.on('message') → MessageDispatcher.dispatch()
                                  → Handler function
                                  → Update state, queue response

Client Disconnect
  ↓
PlayerSession.stop()
  ├── Clear game loop interval
  ├── Save player to database
  └── Remove from ConnectionRegistry
```

### 2. Game Loop (10ms tick rate)

```typescript
class PlayerSession {
  private tickInterval: NodeJS.Timer | null = null;
  private tickCounter = 0;

  start() {
    this.tickInterval = setInterval(() => this.tick(), 10); // 100 ticks/sec
  }

  private tick() {
    if (this.player.controller !== 'active') return;

    this.tickCounter++;

    // Advance game time based on gameSpeed
    const ticksNeeded = this.getTicksForSpeed();
    if (this.tickCounter >= ticksNeeded) {
      this.tickCounter = 0;
      this.advanceMinute();
    }
  }

  private advanceMinute() {
    this.player.minuteOfHour++;

    // Minute tick: random events, location updates
    this.processMinuteTick();

    if (this.player.minuteOfHour >= 60) {
      this.player.minuteOfHour = 0;
      this.player.hourOfDay++;
      this.processHourTick();  // Batched update, mood, events
    }

    if (this.player.hourOfDay >= 24) {
      this.player.hourOfDay = 0;
      this.processDayTick();   // Energy recharge, birthday checks
    }

    // Weekly tick on Monday midnight
    if (this.player.dayOfWeek === 1 && this.player.hourOfDay === 0) {
      this.processWeekTick();  // Save game, finances, relationships
    }
  }
}
```

### 3. Message Dispatcher (Table-Driven)

```typescript
// handlers/index.ts
type CommandHandler = (payload: unknown, session: PlayerSession) => Promise<void>;

export const COMMAND_REGISTRY: Record<string, CommandHandler> = {
  // Game Control
  'start': handleStart,
  'stop': handleStop,
  'restart': handleRestart,
  'speed': handleSpeed,

  // Character
  'characterSetup': handleCharacterSetup,
  'deviceToken': handleDeviceToken,

  // Activities
  'applyForExtracurricular': handleApplyExtracurricular,
  'quitExtracurricular': handleQuitExtracurricular,
  'applyForJob': handleApplyJob,
  'quitJob': handleQuitJob,

  // Conversations
  'conversation': handleConversation,
  'retrievePerson': handleRetrievePerson,
  'markConversationAsRead': handleMarkRead,

  // Habits
  'quitHabit': handleQuitHabit,
  'stopQuitHabit': handleStopQuitHabit,

  // Purchases
  'purchaseItem': handlePurchaseItem,
  'purchaseInAppItem': handlePurchaseInAppItem,
  'purchaseEnergyRefill': handlePurchaseEnergyRefill,
  'purchaseTimeSkip': handlePurchaseTimeSkip,
  'getEnergyRefillTiers': handleGetEnergyTiers,
  'getTimeSkipTiers': handleGetTimeSkipTiers,

  // Achievements & Dailies
  'getAchievements': handleGetAchievements,
  'acknowledgeAchievement': handleAcknowledgeAchievement,
  'getDailyRewards': handleGetDailyRewards,
  'claimDailyReward': handleClaimDailyReward,
  'getDailyQuests': handleGetDailyQuests,
  'claimQuestReward': handleClaimQuestReward,

  // Events
  'claimEvent': handleClaimEvent,

  // Romance
  'romance': handleRomance,
  'dateNight': handleDateNight,
  'breakUp': handleBreakUp,
  'divorce': handleDivorce,
  'partnerGift': handlePartnerGift,
  'getSwipeCharacter': handleGetSwipeCharacter,
  'swipeMatch': handleSwipeMatch,

  // Data Management
  'exportData': handleExportData,
  'deleteAccount': handleDeleteAccount,
  'getPlayerStatistics': handleGetStatistics,
};

// MessageDispatcher.ts
export async function dispatch(
  command: string,
  payload: unknown,
  session: PlayerSession
): Promise<void> {
  const handler = COMMAND_REGISTRY[command] ?? handleGenericEvent;

  try {
    await handler(payload, session);
  } catch (error) {
    // Cost rollback on error
    session.rollbackPendingCosts();
    session.sendError(error.message);
  }
}
```

### 4. Message Protocol (Unchanged)

**Incoming (Client → Server):**
```typescript
interface ClientMessage {
  type: string;      // Command type
  message?: unknown; // Payload
}
```

**Outgoing (Server → Client):**
```typescript
// Lightweight update
{ type: 'u', energy, money, diamonds, time, location, mood }

// Batched update (14+ fields)
{ type: 'batch_update', updates: { date, hourOfDay, season, ... } }

// Full player state
{ type: 'playerObject', player: Player }

// Events
{ type: 'messageEvent', event: MessageEvent }
{ type: 'questionEvent', question: Question }
{ type: 'conversationEvent', conversation: ConversationMessage }
{ type: 'relationshipEvent', event: RelationshipEvent }

// Retention
{ type: 'achievementsList', achievements: Achievement[] }
{ type: 'achievementUnlocked', achievement: Achievement }
{ type: 'dailyRewardClaimed', reward: DailyReward }
{ type: 'questRewardClaimed', quest: DailyQuest }

// Monetization
{ type: 'energyRefillTiers', tiers: EnergyTier[] }
{ type: 'timeSkipTiers', tiers: TimeSkipTier[] }
```

### 5. Database Layer

```typescript
// database/pool.ts
import mysql from 'mysql2/promise';
import { config } from '../config';

export const pool = mysql.createPool({
  host: config.DB_HOST,
  port: config.DB_PORT,
  user: config.DB_USER,
  password: config.DB_PASSWORD,
  database: config.DB_NAME,
  waitForConnections: true,
  connectionLimit: 20,
  queueLimit: 0,
  enableKeepAlive: true,
  keepAliveInitialDelay: 10000,
});

// database/queries.ts
export async function query<T>(sql: string, params?: unknown[]): Promise<T[]> {
  const [rows] = await pool.execute(sql, params);
  return rows as T[];
}

export async function queryOne<T>(sql: string, params?: unknown[]): Promise<T | null> {
  const rows = await query<T>(sql, params);
  return rows[0] ?? null;
}

export async function execute(sql: string, params?: unknown[]): Promise<ResultSetHeader> {
  const [result] = await pool.execute(sql, params);
  return result as ResultSetHeader;
}

// database/transactions.ts
export async function transaction<T>(
  fn: (conn: PoolConnection) => Promise<T>
): Promise<T> {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const result = await fn(conn);
    await conn.commit();
    return result;
  } catch (error) {
    await conn.rollback();
    throw error;
  } finally {
    conn.release();
  }
}
```

### 6. Player Cache

```typescript
// utils/playerCache.ts
interface CacheEntry {
  player: Player;
  lastAccess: number;
}

class PlayerCache {
  private cache = new Map<string, CacheEntry>();
  private maxSize = 1000;
  private ttl = 30 * 60 * 1000; // 30 minutes

  get(userId: string): Player | null {
    const entry = this.cache.get(userId);
    if (!entry) return null;

    entry.lastAccess = Date.now();
    return entry.player;
  }

  set(userId: string, player: Player): void {
    if (this.cache.size >= this.maxSize) {
      this.evictOldest();
    }
    this.cache.set(userId, { player, lastAccess: Date.now() });
  }

  delete(userId: string): void {
    this.cache.delete(userId);
  }

  private evictOldest(): void {
    let oldest: string | null = null;
    let oldestTime = Infinity;

    for (const [key, entry] of this.cache) {
      if (entry.lastAccess < oldestTime) {
        oldestTime = entry.lastAccess;
        oldest = key;
      }
    }

    if (oldest) this.cache.delete(oldest);
  }

  logStats(): void {
    console.log(`PlayerCache: ${this.cache.size}/${this.maxSize} entries`);
  }
}

export const playerCache = new PlayerCache();
```

### 7. Event System

```typescript
// events/types.ts
interface GameEvent {
  id: string;
  applicable: (player: Player) => boolean;
  trigger: (player: Player) => Promise<MessageEvent | Question>;
  priority?: number; // Lower = higher priority
}

// events/index.ts
import { tutorialEvents } from './tutorial';
import { childhoodEvents } from './childhood';
// ... all event imports

const EVENT_REGISTRY: GameEvent[] = [
  ...tutorialEvents,
  ...childhoodEvents,
  ...adolescenceEvents,
  ...adulthoodEvents,
  ...educationEvents,
  ...healthEvents,
  ...activityEvents,
  ...holidayEvents,
  ...conversationEvents,
  ...dilemmaEvents,
  ...negativeEvents,
  ...randomEvents,
  ...schoolYearEvents,
].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));

export function getApplicableEvents(player: Player): GameEvent[] {
  return EVENT_REGISTRY.filter(event =>
    !player.events.has(event.id) && event.applicable(player)
  );
}

export async function triggerEvent(
  event: GameEvent,
  player: Player
): Promise<MessageEvent | Question> {
  player.events.add(event.id); // Mark as triggered (O(1) dedup)
  return event.trigger(player);
}
```

### 8. Background Tasks

```typescript
// index.ts
async function startBackgroundTasks() {
  // Every-minute task (same as Python)
  setInterval(async () => {
    await iterateOfflineGames();
    playerCache.logStats();
  }, 60_000);
}

async function iterateOfflineGames() {
  // Process one tick for disconnected players
  const offlinePlayers = await getOfflinePlayers();
  for (const player of offlinePlayers) {
    await processOfflineTick(player);
  }
}

// Graceful shutdown
async function shutdown() {
  console.log('Shutting down...');

  // Stop accepting new connections
  wss.close();

  // Save all active players
  for (const session of connectionRegistry.values()) {
    await session.savePlayer();
  }

  // Close database pool
  await pool.end();

  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```

---

## Configuration

```typescript
// config.ts
export const config = {
  // Server
  PORT: parseInt(process.env.PORT ?? '8001'),

  // Database
  DB_HOST: process.env.DB_HOST ?? 'localhost',
  DB_PORT: parseInt(process.env.DB_PORT ?? '3306'),
  DB_USER: process.env.DB_USER ?? 'root',
  DB_PASSWORD: process.env.DB_PASSWORD ?? '',
  DB_NAME: process.env.DB_NAME ?? 'baolife',

  // AI
  OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '',
  CONVERSATION_MODEL: process.env.CONVERSATION_MODEL ?? 'gpt-4o-mini',

  // Game
  TICK_INTERVAL: 10, // ms
  SPEED_PAUSED: 10000,
  SPEED_DEFAULT: 10000,
  SPEED_BUTTON_VALUES: [10000, 1000, 500, 50, 20, 1],

  // Limits
  MAX_CONNECTIONS: 20,
  RATE_LIMIT_PER_MINUTE: 30,

  // Debug
  DEBUG: process.env.DEBUG === 'true',
};
```

---

## Testing Strategy

**Unit Tests:**
- Command handlers (mock PlayerSession)
- Event applicability functions
- Game time calculations
- Cost calculations

**Integration Tests:**
- WebSocket connection lifecycle
- Full message round-trips
- Database operations

**Test Utilities:**
```typescript
// tests/fixtures/mockSession.ts
export function createMockSession(overrides?: Partial<Player>): PlayerSession {
  const player = createTestPlayer(overrides);
  return new PlayerSession(mockWebSocket, player);
}

// tests/fixtures/testPlayer.ts
export function createTestPlayer(overrides?: Partial<Player>): Player {
  return {
    userId: 'test-user',
    status: 'playing',
    hourOfDay: 12,
    // ... defaults
    ...overrides,
  };
}
```

---

## Deployment

**Development:**
```bash
npm run dev  # ts-node with watch
```

**Production:**
```bash
npm run build  # tsc
npm start      # node dist/index.js
```

**Docker:**
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
EXPOSE 8001
CMD ["node", "dist/index.js"]
```

**PM2:**
```bash
pm2 start dist/index.js --name baolife-server -i max
```

---

## Migration Plan

1. **Deploy Node.js server** on separate port (8002)
2. **Internal testing** with test accounts
3. **Load testing** to verify performance
4. **Switch load balancer** from Python (8001) to Node.js (8002)
5. **Monitor** for 24 hours
6. **Keep Python as fallback** - can switch back instantly
7. **Decommission Python** after stable period

---

## Success Criteria

- [ ] All 50+ command handlers implemented
- [ ] All 18 event categories ported
- [ ] Full protocol compatibility (zero iOS changes)
- [ ] Tests passing with >80% coverage
- [ ] Performance: <10ms average message latency
- [ ] Load test: 100+ concurrent connections stable
