Skip to content

開発用モック・テストモード設計 / Development Mock & Test Mode Design

概要 / Overview

開発・ステージング・本番の各環境における動作モードと、プラットフォームモック・LLMモック・時間加速などの開発効率化ツールを定義する。APIコスト削減、高速イテレーション、シナリオ再現を目的とする。

This document defines environment modes (development, staging, production) and developer productivity tools including platform mocks, LLM mocks, and time acceleration. Goals: reduce API costs, enable fast iteration, and reproduce test scenarios.


1. 環境モード / Environment Modes

1.1 モード一覧 / Mode Overview

ModeNODE_ENVLLMPlatformTimeDBUse Case
developmentdevelopmentMock (default)MockAcceleratedIn-memory SQLiteローカル開発 / Local dev
stagingstagingReal (Claude Haiku 4.5)MockNormalFile SQLite結合テスト / Integration test
productionproductionReal (Claude Haiku 4.5)Real (YT + TikTok)NormalFile SQLite本番配信 / Live stream

1.2 環境変数 / Environment Variables

bash
# .env.development (default)
NODE_ENV=development
MOCK_LLM=true
MOCK_PLATFORM=true
TIME_SCALE=60            # 10-min cycle → 10 seconds
MOCK_WEB_UI=true         # Enable mock control Web UI
DB_MODE=memory           # In-memory SQLite

# .env.staging
NODE_ENV=staging
MOCK_LLM=false
MOCK_PLATFORM=true       # Still mock platforms in staging
TIME_SCALE=1             # Normal speed
MOCK_WEB_UI=true
DB_MODE=file
ANTHROPIC_API_KEY=sk-ant-xxxxx

# .env.production
NODE_ENV=production
MOCK_LLM=false
MOCK_PLATFORM=false
TIME_SCALE=1
MOCK_WEB_UI=false
DB_MODE=file
ANTHROPIC_API_KEY=sk-ant-xxxxx
YOUTUBE_API_KEY=xxxxx
TIKTOK_SESSION_ID=xxxxx

1.3 モード判定ロジック / Mode Detection Logic

typescript
interface AppConfig {
  env: 'development' | 'staging' | 'production'
  useMockLLM: boolean
  useMockPlatform: boolean
  timeScale: number       // 1 = normal, 60 = 60x acceleration
  enableMockWebUI: boolean
  dbMode: 'memory' | 'file'
}

function loadConfig(): AppConfig {
  const env = (process.env.NODE_ENV ?? 'development') as AppConfig['env']
  return {
    env,
    useMockLLM: process.env.MOCK_LLM === 'true',
    useMockPlatform: process.env.MOCK_PLATFORM === 'true',
    timeScale: parseInt(process.env.TIME_SCALE ?? '1', 10),
    enableMockWebUI: process.env.MOCK_WEB_UI === 'true',
    dbMode: (process.env.DB_MODE ?? 'file') as 'memory' | 'file',
  }
}

2. プラットフォームモック / Platform Mock

2.1 概要 / Overview

YouTube / TikTok に接続せずに投げ銭・コメントを擬似的に発生させる仕組み。開発時の Web UI から手動送信と、自動生成の両方をサポートする。

A system to simulate tips and comments without connecting to YouTube / TikTok. Supports both manual sending from a dev Web UI and auto-generation.

2.2 アーキテクチャ / Architecture

┌──────────────────────────────────────────────────────────┐
│                  Mock Control Web UI                      │
│                  http://localhost:3001                    │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Manual Controls / 手動操作                        │  │
│  │                                                    │  │
│  │  [Platform: YouTube ▼] [Type: Super Chat ▼]       │  │
│  │  [Username: testuser ]                             │  │
│  │  [Amount: ¥500      ] [Message: こたつ買って ]      │  │
│  │  [Send Tip]                                        │  │
│  │                                                    │  │
│  │  [Comment: がんばれー!    ] [Lang: JP ▼]          │  │
│  │  [Send Comment]                                    │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Auto Generator / 自動生成                         │  │
│  │                                                    │  │
│  │  Comments: [●ON ] every [30 ] sec,  [3 ] per batch │  │
│  │  Tips:     [○OFF] every [120] sec,  Avg ¥[300]     │  │
│  │                                                    │  │
│  │  [Run Scenario ▼]  [▶ Start]  [■ Stop]            │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │  Live Monitor / リアルタイムモニター                │  │
│  │                                                    │  │
│  │  12:00:03 [TIP]     YouTube testuser ¥500 こたつ    │  │
│  │  12:00:15 [COMMENT] TikTok  user42   がんばれー!   │  │
│  │  12:00:16 [COMMENT] YouTube viewer1  Go John!       │  │
│  │  12:00:30 [CYCLE]   Turn #5 started                 │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

         │ REST API / WebSocket

┌──────────────────────────────────────────────────────────┐
│  MockPlatformAdapter                                      │
│                                                          │
│  ┌─────────────────┐  ┌─────────────────┐                │
│  │ MockYouTube      │  │ MockTikTok      │                │
│  │ Module           │  │ Module          │                │
│  └────────┬────────┘  └────────┬────────┘                │
│           │                    │                          │
│           ▼                    ▼                          │
│  ┌──────────────────────────────────────────────────┐    │
│  │         Unified Event Queue (same as prod)        │    │
│  └──────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────┘

2.3 MockPlatformAdapter

typescript
interface MockTipOptions {
  platform: 'youtube' | 'tiktok'
  username?: string
  amount?: number          // JPY for YouTube, coins for TikTok
  message?: string
  giftName?: string        // TikTok only
  comboCount?: number      // TikTok only
}

interface MockCommentOptions {
  platform: 'youtube' | 'tiktok'
  username?: string
  message: string
  language?: 'jp' | 'en'
}

class MockPlatformAdapter {
  private eventQueue: EventQueue

  // 手動投げ銭送信 / Manual tip injection
  sendTip(options: MockTipOptions): TipEvent {
    const event: TipEvent = {
      platform: options.platform,
      username: options.username ?? this.randomUsername(),
      amount: options.amount ?? 100,
      currency: options.platform === 'youtube' ? 'JPY' : 'TIKTOK_COINS',
      message: options.message,
      giftName: options.giftName,
      comboCount: options.comboCount,
      timestamp: Date.now(),
    }
    this.eventQueue.push(event)
    return event
  }

  // 手動コメント送信 / Manual comment injection
  sendComment(options: MockCommentOptions): CommentEvent {
    const event: CommentEvent = {
      platform: options.platform,
      username: options.username ?? this.randomUsername(),
      message: options.message,
      language: options.language ?? 'jp',
      timestamp: Date.now(),
    }
    this.eventQueue.push(event)
    return event
  }

  // ランダムユーザー名 / Random username
  private randomUsername(): string {
    const names = [
      'viewer_tanaka', 'sakura_fan', 'john_supporter',
      'eve_lover', 'pixel_watcher', 'chat_hero',
      'tiktok_user42', 'yt_subscriber', 'night_owl',
      'morning_viewer', 'sara_fan_99', 'dog_person',
    ]
    return names[Math.floor(Math.random() * names.length)]
  }
}

2.4 自動コメント・投げ銭ジェネレーター / Auto Comment & Tip Generator

typescript
interface AutoGenConfig {
  comments: {
    enabled: boolean
    intervalSec: number     // 生成間隔(秒)/ Interval (seconds)
    batchSize: number       // 1回あたりのコメント数 / Comments per batch
  }
  tips: {
    enabled: boolean
    intervalSec: number     // 平均間隔(秒)/ Average interval (seconds)
    avgAmount: number       // 平均金額(JPY)/ Average amount (JPY)
    distribution: 'uniform' | 'exponential'
  }
}

class AutoGenerator {
  private config: AutoGenConfig
  private adapter: MockPlatformAdapter
  private timers: NodeJS.Timeout[] = []

  // コメントテンプレート / Comment templates
  private readonly COMMENT_TEMPLATES = {
    jp: [
      'ジョンがんばれ!', 'サラかわいい!', 'イヴ〜!🐕',
      'おなかすいてそう', '仲良しだね', 'おやすみ〜',
      '今日も楽しい', 'けんかしないで!', 'ごはん食べて!',
      'お散歩いこうよ', 'こたつほしいね', '二人ともお疲れ様',
      'イヴのごはんは?', '掃除した方がいいよ', '映画見よう!',
    ],
    en: [
      'Go John!', 'Sara is cute!', 'Eve! 🐕',
      'They look hungry', 'So sweet together', 'Good night!',
      'Fun stream today', 'Don\'t fight!', 'Eat something!',
      'Go for a walk!', 'They need a kotatsu', 'Great job both!',
      'Feed Eve!', 'Time to clean up', 'Watch a movie!',
    ],
  }

  start(): void {
    if (this.config.comments.enabled) {
      const timer = setInterval(() => {
        for (let i = 0; i < this.config.comments.batchSize; i++) {
          const lang = Math.random() > 0.3 ? 'jp' : 'en'
          const templates = this.COMMENT_TEMPLATES[lang]
          this.adapter.sendComment({
            platform: Math.random() > 0.5 ? 'youtube' : 'tiktok',
            message: templates[Math.floor(Math.random() * templates.length)],
            language: lang,
          })
        }
      }, this.config.comments.intervalSec * 1000)
      this.timers.push(timer)
    }

    if (this.config.tips.enabled) {
      const scheduleTip = () => {
        const jitter = (Math.random() * 0.5 + 0.75) // 0.75x ~ 1.25x
        const delay = this.config.tips.intervalSec * 1000 * jitter
        const timer = setTimeout(() => {
          const amount = this.randomTipAmount()
          this.adapter.sendTip({
            platform: Math.random() > 0.5 ? 'youtube' : 'tiktok',
            amount,
          })
          scheduleTip()
        }, delay)
        this.timers.push(timer)
      }
      scheduleTip()
    }
  }

  stop(): void {
    this.timers.forEach(t => clearTimeout(t))
    this.timers = []
  }

  private randomTipAmount(): number {
    // よくある投げ銭額から選択 / Select from common tip amounts
    const amounts = [100, 200, 300, 500, 1000, 2000, 5000]
    return amounts[Math.floor(Math.random() * amounts.length)]
  }
}

2.5 Mock Web UI API エンドポイント / Mock Web UI API Endpoints

typescript
// Express routes for mock control (development only)
if (config.enableMockWebUI) {
  // 手動投げ銭送信 / Manual tip
  app.post('/api/mock/tip', (req, res) => {
    const event = mockAdapter.sendTip(req.body)
    res.json({ ok: true, event })
  })

  // 手動コメント送信 / Manual comment
  app.post('/api/mock/comment', (req, res) => {
    const event = mockAdapter.sendComment(req.body)
    res.json({ ok: true, event })
  })

  // 自動生成の開始・停止 / Start/stop auto generator
  app.post('/api/mock/auto/start', (req, res) => {
    autoGenerator.start()
    res.json({ ok: true, status: 'started' })
  })

  app.post('/api/mock/auto/stop', (req, res) => {
    autoGenerator.stop()
    res.json({ ok: true, status: 'stopped' })
  })

  // シナリオ実行 / Run scenario
  app.post('/api/mock/scenario/:name', (req, res) => {
    const result = scenarioRunner.run(req.params.name)
    res.json({ ok: true, result })
  })

  // 静的ファイル提供 / Serve mock UI static files
  app.use('/mock', express.static('packages/backend/src/mock/ui'))
}

3. LLM モック / LLM Mock

3.1 概要 / Overview

Claude API を呼ばずにダミー応答を返すモック。開発時の API コスト節約とオフライン開発を可能にする。

A mock that returns dummy responses without calling the Claude API. Enables API cost savings and offline development.

3.2 モック応答戦略 / Mock Response Strategy

StrategyDescriptionUse Case
template定型テンプレートからランダム選択 / Random selection from fixed templates高速・安定。基本動作確認 / Fast & stable. Basic checks
rule-basedステータスに基づくルールベース応答 / Rule-based responses from game stateロジック検証。状態遷移テスト / Logic validation. State transition tests
recorded録画済み実LLM応答のリプレイ / Replay of recorded real LLM responsesリアルな動作確認 / Realistic behavior checks

3.3 MockLLMClient 実装 / MockLLMClient Implementation

typescript
interface AgentDecision {
  action: string           // 行動カテゴリ / Action category
  target?: string          // 対象 / Target (character, item, location)
  dialogue_jp: string      // 日本語セリフ / Japanese dialogue
  dialogue_en: string      // 英語セリフ / English dialogue
  mood_change: number      // 気分変化 / Mood change (-10 ~ +10)
  thought: string          // 内部思考(ログ用)/ Internal thought (for logs)
}

class MockLLMClient {
  private strategy: 'template' | 'rule-based' | 'recorded'

  async generateDecision(
    character: 'john' | 'sara' | 'eve',
    gameState: GameState,
    context: TurnContext,
  ): Promise<AgentDecision> {
    switch (this.strategy) {
      case 'template':
        return this.templateResponse(character, gameState)
      case 'rule-based':
        return this.ruleBasedResponse(character, gameState, context)
      case 'recorded':
        return this.recordedResponse(character, context)
    }
  }

  // テンプレート応答 / Template response
  private templateResponse(
    character: 'john' | 'sara' | 'eve',
    state: GameState,
  ): AgentDecision {
    const templates: Record<string, AgentDecision[]> = {
      john: [
        {
          action: 'cook',
          dialogue_jp: '今日は僕が料理するよ!',
          dialogue_en: "I'll cook today!",
          mood_change: 2,
          thought: 'Hungry, should cook',
        },
        {
          action: 'work',
          dialogue_jp: '仕事しないとな…',
          dialogue_en: 'Gotta get to work...',
          mood_change: -1,
          thought: 'Need to earn money',
        },
        {
          action: 'rest',
          dialogue_jp: 'ちょっと休憩しよう',
          dialogue_en: "Let's take a break",
          mood_change: 3,
          thought: 'Feeling tired',
        },
        {
          action: 'talk',
          target: 'sara',
          dialogue_jp: 'サラ、今日どうだった?',
          dialogue_en: 'Sara, how was your day?',
          mood_change: 2,
          thought: 'Want to talk to Sara',
        },
      ],
      sara: [
        {
          action: 'cook',
          dialogue_jp: '何か作ろうかな♪',
          dialogue_en: "Let me whip something up!",
          mood_change: 2,
          thought: 'Feel like cooking',
        },
        {
          action: 'clean',
          dialogue_jp: 'お部屋きれいにしよう!',
          dialogue_en: "Let's clean up the room!",
          mood_change: 1,
          thought: 'Room is messy',
        },
        {
          action: 'play_eve',
          target: 'eve',
          dialogue_jp: 'イヴ〜、遊ぼう!',
          dialogue_en: 'Eve, let\'s play!',
          mood_change: 3,
          thought: 'Eve looks bored',
        },
        {
          action: 'talk',
          target: 'john',
          dialogue_jp: 'ジョン、ちょっと聞いて!',
          dialogue_en: 'John, listen to this!',
          mood_change: 2,
          thought: 'Want to chat with John',
        },
      ],
      eve: [
        {
          action: 'sleep',
          dialogue_jp: '(すやすや…)',
          dialogue_en: '(zzz...)',
          mood_change: 1,
          thought: 'Sleepy',
        },
        {
          action: 'play',
          dialogue_jp: 'ワン!',
          dialogue_en: 'Woof!',
          mood_change: 3,
          thought: 'Want to play',
        },
        {
          action: 'eat',
          dialogue_jp: '(もぐもぐ)',
          dialogue_en: '(munch munch)',
          mood_change: 2,
          thought: 'Hungry',
        },
      ],
    }

    const charTemplates = templates[character]
    return charTemplates[Math.floor(Math.random() * charTemplates.length)]
  }

  // ルールベース応答 / Rule-based response
  private ruleBasedResponse(
    character: 'john' | 'sara' | 'eve',
    state: GameState,
    context: TurnContext,
  ): AgentDecision {
    const charState = state.characters[character]

    // ステータスに応じた行動選択 / Action selection based on status
    if (charState.hunger <= 20) {
      return {
        action: 'eat',
        dialogue_jp: 'おなかすいた…',
        dialogue_en: "I'm starving...",
        mood_change: -1,
        thought: `Hunger critical: ${charState.hunger}`,
      }
    }
    if (charState.energy <= 20) {
      return {
        action: 'sleep',
        dialogue_jp: '眠い…もう寝よう',
        dialogue_en: "So sleepy... time to rest",
        mood_change: 0,
        thought: `Energy critical: ${charState.energy}`,
      }
    }
    if (charState.stress >= 80) {
      return {
        action: 'rest',
        dialogue_jp: 'ちょっと疲れたなぁ',
        dialogue_en: "I'm exhausted...",
        mood_change: 1,
        thought: `Stress high: ${charState.stress}`,
      }
    }

    // デフォルト / Default
    return this.templateResponse(character, state)
  }

  // 録画リプレイ / Recorded replay
  private recordedResponse(
    character: 'john' | 'sara' | 'eve',
    context: TurnContext,
  ): AgentDecision {
    // data/mock/recorded_responses.json から順番にリプレイ
    // Replay sequentially from data/mock/recorded_responses.json
    return this.recordedData[character].shift()
      ?? this.templateResponse(character, {} as GameState)
  }
}

3.4 投げ銭リアクションモック / Tip Reaction Mock

typescript
class MockLLMClient {
  async generateTipReaction(
    tipEvent: TipEvent,
    gameState: GameState,
  ): Promise<TipReaction> {
    const amount = tipEvent.platform === 'youtube'
      ? tipEvent.amount
      : Math.floor(tipEvent.amount * 1.5)

    // 金額帯に応じたリアクション / Reaction based on amount tier
    if (amount >= 5000) {
      return {
        john: { dialogue_jp: 'えっ!すごい!ありがとうございます!', dialogue_en: 'Wow! Amazing! Thank you so much!' },
        sara: { dialogue_jp: 'うそっ!嬉しい〜!', dialogue_en: 'No way! So happy!' },
        eve: { dialogue_jp: 'ワンワン!!', dialogue_en: 'Woof woof!!' },
      }
    }
    if (amount >= 1000) {
      return {
        john: { dialogue_jp: 'おぉ、ありがとう!', dialogue_en: 'Oh, thank you!' },
        sara: { dialogue_jp: 'ありがとうございます♪', dialogue_en: 'Thank you!' },
        eve: { dialogue_jp: 'ワン!', dialogue_en: 'Woof!' },
      }
    }
    return {
      john: { dialogue_jp: 'ありがとう!', dialogue_en: 'Thanks!' },
      sara: { dialogue_jp: 'わぁ、嬉しい!', dialogue_en: 'Yay, nice!' },
      eve: { dialogue_jp: '(しっぽフリフリ)', dialogue_en: '(tail wag)' },
    }
  }
}

3.5 コメント応答モック / Comment Response Mock

typescript
class MockLLMClient {
  async generateCommentResponse(
    comments: CommentEvent[],
    gameState: GameState,
  ): Promise<CommentReaction> {
    // 最初のコメントにだけ応答(簡易モック)
    // Respond to just the first comment (simplified mock)
    const comment = comments[0]
    if (!comment) return { responses: [] }

    const isJP = comment.language === 'jp'
    const responder = Math.random() > 0.5 ? 'john' : 'sara'

    return {
      responses: [{
        character: responder,
        to: comment.username,
        dialogue_jp: isJP
          ? `${comment.username}さん、コメントありがとう!`
          : `${comment.username}、コメントありがとう!`,
        dialogue_en: `Thanks for the comment, ${comment.username}!`,
      }],
    }
  }
}

4. 時間加速 / Time Acceleration

4.1 概要 / Overview

本番では10分間隔のゲームサイクルを、開発環境では10秒に短縮して高速テストを可能にする。

In production, game cycles run at 10-minute intervals. In development, this is compressed to 10 seconds for rapid testing.

4.2 加速設定 / Acceleration Settings

TIME_SCALEサイクル間隔 / Cycle Interval用途 / Use Case
110 minutes (600 sec)本番・ステージング / Production, staging
6100 secondsスロー開発確認 / Slow dev check
6010 seconds高速開発 / Fast development
6001 second超高速テスト / Ultra-fast automated test

4.3 実装方針 / Implementation Approach

typescript
class TurnScheduler {
  private config: AppConfig
  private readonly BASE_CYCLE_MS = 10 * 60 * 1000  // 10 minutes

  get cycleDurationMs(): number {
    return this.BASE_CYCLE_MS / this.config.timeScale
  }

  // ゲーム内時刻の進行も加速する / In-game time also accelerates
  getGameTimeDelta(realElapsedMs: number): number {
    return realElapsedMs * this.config.timeScale
  }

  start(): void {
    const intervalMs = this.cycleDurationMs
    console.log(
      `TurnScheduler started: cycle=${intervalMs}ms ` +
      `(TIME_SCALE=${this.config.timeScale}, ` +
      `${this.config.timeScale === 1 ? 'real-time' : this.config.timeScale + 'x acceleration'})`
    )

    setInterval(() => {
      this.executeCycle()
    }, intervalMs)
  }
}

4.4 加速時のステータス変化 / Status Changes Under Acceleration

時間加速中もステータス変化量は現実の10分間相当を維持する(加速率に応じてスケーリングしない)。これにより、60x加速で10秒ごとに「10分経過相当」のステータス変化が発生する。

During time acceleration, status change amounts remain equivalent to 10 real minutes (not scaled by acceleration rate). At 60x, every 10 seconds triggers status changes equivalent to 10 minutes passing.

typescript
// ステータス変化は TIME_SCALE に関係なく一定
// Status changes are constant regardless of TIME_SCALE
function calculateStatusDelta(): StatusDelta {
  // 1サイクルあたりの固定変化量(10分相当)
  // Fixed change per cycle (equivalent to 10 minutes)
  return {
    hunger: -3,     // 空腹度 -3 per cycle
    energy: -2,     // 体力 -2 per cycle
    stress: +1,     // ストレス +1 per cycle
    mood: 0,        // 気分は行動依存 / Mood depends on action
  }
}

5. テストシナリオ / Test Scenarios

5.1 シナリオ一覧 / Scenario List

#Scenario Name目的 / Purpose操作 / Steps
S1tip-reaction投げ銭の即時反応を検証 / Verify instant tip reactions各金額帯の投げ銭を送信、キャラ反応・アイテム付与を確認
S2comment-responseコメント応答を検証 / Verify comment responsesJP/EN コメントを送信、10分サイクルで応答を確認
S3status-zeroステータス0時の挙動を検証 / Verify behavior when status=0hunger=0 まで放置、キャラの緊急行動を確認
S4argument-makeup喧嘩→仲直りフローを検証 / Verify argument → makeup flowlove値を低下させ、喧嘩発生→自動仲直りを確認
S5duplicate-item重複アイテム購入を検証 / Verify duplicate item purchase同一アイテムを2回購入、代替選択・スタック動作を確認

5.2 S1: 投げ銭リアクション / Tip Reaction

typescript
const SCENARIO_TIP_REACTION = {
  name: 'tip-reaction',
  description_jp: '各金額帯の投げ銭に対するキャラクター反応を検証',
  description_en: 'Verify character reactions to tips at each amount tier',
  steps: [
    // Step 1: 最小投げ銭(TikTok Rose)
    // Step 1: Minimum tip (TikTok Rose)
    {
      action: 'send_tip',
      params: { platform: 'tiktok', amount: 1, giftName: 'Rose' },
      expect: {
        lc: 2,
        item: null,                    // 2 LC < 100 LC minimum
        balanceIncrease: 2,
        reactionType: 'small',
      },
    },
    // Step 2: Tier 1 投げ銭(YouTube ¥500)
    // Step 2: Tier 1 tip (YouTube ¥500)
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 500, message: 'こたつ買って' },
      expect: {
        lc: 500,
        item: 'kotatsu',
        reactionType: 'medium',
        dialogueContains: 'こたつ',
      },
    },
    // Step 3: 高額投げ銭(YouTube ¥5,000)
    // Step 3: Large tip (YouTube ¥5,000)
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 5000 },
      expect: {
        lc: 5000,
        item: { tier: 3 },            // Tier 3 item auto-selected
        reactionType: 'large',
        effectsTriggered: ['banner', 'sparkle', 'character_celebration'],
      },
    },
    // Step 4: メッセージなし投げ銭
    // Step 4: Tip without message
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 300 },
      expect: {
        lc: 300,
        item: { cost: 300 },          // Auto-match
        reactionType: 'medium',
      },
    },
  ],
}

5.3 S2: コメント応答 / Comment Response

typescript
const SCENARIO_COMMENT_RESPONSE = {
  name: 'comment-response',
  description_jp: 'JP/ENコメントへの10分サイクル応答を検証',
  description_en: 'Verify 10-min cycle responses to JP/EN comments',
  steps: [
    // Step 1: 日本語コメント複数送信
    // Step 1: Send multiple Japanese comments
    {
      action: 'send_comments',
      params: {
        comments: [
          { platform: 'youtube', message: 'ジョンがんばれ!', language: 'jp' },
          { platform: 'tiktok', message: 'サラかわいい!', language: 'jp' },
          { platform: 'youtube', message: 'イヴにごはんあげて', language: 'jp' },
        ],
      },
    },
    // Step 2: 英語コメント送信
    // Step 2: Send English comments
    {
      action: 'send_comments',
      params: {
        comments: [
          { platform: 'youtube', message: 'Go John!', language: 'en' },
          { platform: 'tiktok', message: 'Sara is so cute!', language: 'en' },
        ],
      },
    },
    // Step 3: 次のサイクルを待つ / Wait for next cycle
    {
      action: 'wait_cycle',
      expect: {
        responsesGenerated: true,
        jpResponseInJP: true,          // JP comment → JP response
        enResponseInEN: true,          // EN comment → EN response
        selectedNotableComments: true,  // Not all comments get responses
      },
    },
  ],
}

5.4 S3: ステータスゼロ / Status Zero

typescript
const SCENARIO_STATUS_ZERO = {
  name: 'status-zero',
  description_jp: 'hunger=0でキャラクターが緊急行動を取ることを検証',
  description_en: 'Verify characters take emergency action when hunger=0',
  steps: [
    // Step 1: hunger を強制的に 0 に設定
    // Step 1: Force hunger to 0
    {
      action: 'set_status',
      params: { character: 'john', hunger: 0, energy: 50, mood: 50, stress: 30 },
    },
    {
      action: 'set_status',
      params: { character: 'sara', hunger: 0, energy: 50, mood: 50, stress: 30 },
    },
    // Step 2: サイクルを実行
    // Step 2: Execute a cycle
    {
      action: 'trigger_cycle',
      expect: {
        john: {
          action: 'eat',              // 緊急: 食事 / Emergency: eat
          dialogueContains: ['hungry', 'おなか', 'starving', '食べ'],
        },
        sara: {
          action: ['eat', 'cook'],    // 食事 or 料理 / Eat or cook
        },
      },
    },
    // Step 3: hunger が回復することを確認
    // Step 3: Verify hunger recovers
    {
      action: 'check_status',
      expect: {
        john: { hunger: { greaterThan: 0 } },
        sara: { hunger: { greaterThan: 0 } },
      },
    },
  ],
}

5.5 S4: 喧嘩→仲直り / Argument → Makeup

typescript
const SCENARIO_ARGUMENT_MAKEUP = {
  name: 'argument-makeup',
  description_jp: 'love値低下→喧嘩発生→自動仲直りイベントの一連の流れを検証',
  description_en: 'Verify love decrease → argument trigger → auto makeup event flow',
  steps: [
    // Step 1: love値をフロア付近に設定
    // Step 1: Set love value near floor
    {
      action: 'set_relationship',
      params: { john_sara_love: 45 },  // Floor = 40
    },
    // Step 2: ストレスを上げて喧嘩を誘発
    // Step 2: Increase stress to trigger argument
    {
      action: 'set_status',
      params: { character: 'john', stress: 90, mood: 20 },
    },
    {
      action: 'set_status',
      params: { character: 'sara', stress: 85, mood: 25 },
    },
    // Step 3: サイクルを複数回実行(喧嘩発生を待つ)
    // Step 3: Run multiple cycles (wait for argument)
    {
      action: 'trigger_cycles',
      params: { count: 3 },
      expect: {
        argumentTriggered: true,
        love: { lessThan: 45 },
      },
    },
    // Step 4: love がフロア (40) に近づくと仲直りイベント発動
    // Step 4: Makeup event triggers when love approaches floor (40)
    {
      action: 'trigger_cycles',
      params: { count: 5 },
      expect: {
        makeupEventTriggered: true,
        love: { greaterThanOrEqual: 40 },  // Floor enforced
        dialogueContains: ['ごめん', 'sorry', '仲直り', 'make up'],
      },
    },
  ],
}

5.6 S5: 重複アイテム購入 / Duplicate Item Purchase

typescript
const SCENARIO_DUPLICATE_ITEM = {
  name: 'duplicate-item',
  description_jp: '同一アイテム2回購入時の代替選択・スタック動作を検証',
  description_en: 'Verify alternative selection and stacking on duplicate item purchase',
  steps: [
    // Step 1: 永続品を購入 / Purchase a permanent item
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 500, message: 'こたつ買って' },
      expect: {
        item: 'kotatsu',
        inventoryHas: 'kotatsu',
      },
    },
    // Step 2: 同じ永続品を再度購入(重複)
    // Step 2: Purchase same permanent item again (duplicate)
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 500, message: 'こたつ買って' },
      expect: {
        item: { not: 'kotatsu' },      // 代替アイテムが選ばれる / Alternative selected
        duplicateAction: 'alternative_selected',
        // 代替: Board game (500 LC) or Dog toy set (500 LC) etc.
      },
    },
    // Step 3: 消耗品を購入 / Purchase a consumable
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 100, message: 'カップ麺' },
      expect: {
        item: 'cup_noodles',
        inventoryQuantity: { item: 'cup_noodles', quantity: 1 },
      },
    },
    // Step 4: 同じ消耗品を再度購入(スタック)
    // Step 4: Purchase same consumable again (stack)
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 100, message: 'カップ麺' },
      expect: {
        item: 'cup_noodles',
        inventoryQuantity: { item: 'cup_noodles', quantity: 2 },  // スタック / Stacked
      },
    },
    // Step 5: ストーリーアイテムの重複
    // Step 5: Story item duplicate
    {
      action: 'grant_item',
      params: { itemId: 'baby_flag' },  // 事前に付与 / Pre-grant
    },
    {
      action: 'send_tip',
      params: { platform: 'youtube', amount: 50000 },
      expect: {
        item: null,                     // ストーリーは再発行不可 / Story cannot be re-issued
        duplicateAction: 'story_already_unlocked',
        balanceIncrease: 50000,
        messageContains: ['体験済み', 'already been experienced'],
      },
    },
  ],
}

6. シナリオランナー / Scenario Runner

6.1 概要 / Overview

テストシナリオをプログラム的に実行するランナー。Mock Web UI から選択実行、または CLI から自動実行が可能。

A runner that executes test scenarios programmatically. Can be run from Mock Web UI or automated via CLI.

6.2 実装 / Implementation

typescript
class ScenarioRunner {
  private mockAdapter: MockPlatformAdapter
  private gameState: GameState
  private stateManager: StateManager

  async run(scenarioName: string): Promise<ScenarioResult> {
    const scenario = SCENARIOS[scenarioName]
    if (!scenario) throw new Error(`Unknown scenario: ${scenarioName}`)

    console.log(`[Scenario] Running: ${scenario.name}`)
    const results: StepResult[] = []

    for (const step of scenario.steps) {
      const result = await this.executeStep(step)
      results.push(result)

      if (!result.passed) {
        console.error(`[Scenario] FAIL at step: ${JSON.stringify(step)}`)
        console.error(`[Scenario] Expected: ${JSON.stringify(step.expect)}`)
        console.error(`[Scenario] Actual: ${JSON.stringify(result.actual)}`)
        break
      }
    }

    const passed = results.every(r => r.passed)
    console.log(`[Scenario] ${scenario.name}: ${passed ? 'PASSED' : 'FAILED'}`)
    return { scenario: scenario.name, passed, steps: results }
  }

  private async executeStep(step: ScenarioStep): Promise<StepResult> {
    switch (step.action) {
      case 'send_tip':
        return this.executeSendTip(step)
      case 'send_comment':
      case 'send_comments':
        return this.executeSendComments(step)
      case 'set_status':
        return this.executeSetStatus(step)
      case 'set_relationship':
        return this.executeSetRelationship(step)
      case 'trigger_cycle':
      case 'trigger_cycles':
        return this.executeTriggerCycles(step)
      case 'wait_cycle':
        return this.executeWaitCycle(step)
      case 'check_status':
        return this.executeCheckStatus(step)
      case 'grant_item':
        return this.executeGrantItem(step)
      default:
        throw new Error(`Unknown step action: ${step.action}`)
    }
  }
}

const SCENARIOS: Record<string, Scenario> = {
  'tip-reaction': SCENARIO_TIP_REACTION,
  'comment-response': SCENARIO_COMMENT_RESPONSE,
  'status-zero': SCENARIO_STATUS_ZERO,
  'argument-makeup': SCENARIO_ARGUMENT_MAKEUP,
  'duplicate-item': SCENARIO_DUPLICATE_ITEM,
}

7. モックモードの起動方法 / How to Run Mock Mode

7.1 開発モード起動 / Start Development Mode

bash
# 開発モード(モック有効 + 時間加速60倍)
# Development mode (mocks enabled + 60x time acceleration)
pnpm dev

# 上記は以下と同等 / Equivalent to:
NODE_ENV=development MOCK_LLM=true MOCK_PLATFORM=true TIME_SCALE=60 \
  MOCK_WEB_UI=true DB_MODE=memory pnpm --filter backend dev

7.2 ステージングモード起動 / Start Staging Mode

bash
# ステージングモード(実LLM + プラットフォームモック)
# Staging mode (real LLM + platform mock)
pnpm staging

# 上記は以下と同等 / Equivalent to:
NODE_ENV=staging MOCK_LLM=false MOCK_PLATFORM=true TIME_SCALE=1 \
  MOCK_WEB_UI=true DB_MODE=file pnpm --filter backend dev

7.3 Mock Web UI へのアクセス / Access Mock Web UI

bash
# 開発モードまたはステージングモード起動後
# After starting development or staging mode

# Mock Control UI
open http://localhost:3001/mock

# Game screen (normal frontend)
open http://localhost:3000

7.4 CLI からのシナリオ実行 / Run Scenarios from CLI

bash
# 特定のシナリオを実行 / Run a specific scenario
pnpm test:scenario tip-reaction

# 全シナリオを実行 / Run all scenarios
pnpm test:scenario:all

# 時間加速を変更して実行 / Run with different time scale
TIME_SCALE=600 pnpm test:scenario status-zero

7.5 package.json スクリプト / package.json Scripts

json
{
  "scripts": {
    "dev": "dotenv -e .env.development -- tsx watch packages/backend/src/server.ts",
    "staging": "dotenv -e .env.staging -- tsx watch packages/backend/src/server.ts",
    "start": "dotenv -e .env.production -- node dist/server.js",
    "test:scenario": "dotenv -e .env.development -- tsx packages/backend/src/mock/scenario-runner.ts",
    "test:scenario:all": "dotenv -e .env.development -- tsx packages/backend/src/mock/scenario-runner.ts --all"
  }
}

8. ファイル構成 / File Structure

packages/backend/src/
├── mock/
│   ├── MockPlatformAdapter.ts    # Platform mock (tip & comment injection)
│   ├── MockLLMClient.ts          # LLM mock (template/rule-based/recorded)
│   ├── AutoGenerator.ts          # Auto comment & tip generator
│   ├── ScenarioRunner.ts         # Test scenario runner
│   ├── scenarios/
│   │   ├── tip-reaction.ts       # S1: Tip reaction scenario
│   │   ├── comment-response.ts   # S2: Comment response scenario
│   │   ├── status-zero.ts        # S3: Status zero scenario
│   │   ├── argument-makeup.ts    # S4: Argument → makeup scenario
│   │   └── duplicate-item.ts     # S5: Duplicate item scenario
│   └── ui/
│       ├── index.html            # Mock control Web UI
│       ├── style.css
│       └── app.js
├── agents/
│   ├── AgentManager.ts           # Uses MockLLMClient or real Claude client
│   └── ...
├── platforms/
│   ├── PlatformAdapter.ts        # Interface (real or mock)
│   └── ...
└── config.ts                     # Environment config loader

9. DI(依存性注入)による切り替え / Dependency Injection for Switching

9.1 概要 / Overview

Mock と Real の実装を環境変数に基づいて DI で切り替える。コード変更なしでモードを切り替え可能にする。

Switch between Mock and Real implementations via DI based on environment variables. Enables mode switching without code changes.

9.2 実装例 / Implementation Example

typescript
// interfaces
interface ILLMClient {
  generateDecision(character: string, state: GameState, ctx: TurnContext): Promise<AgentDecision>
  generateTipReaction(event: TipEvent, state: GameState): Promise<TipReaction>
  generateCommentResponse(comments: CommentEvent[], state: GameState): Promise<CommentReaction>
}

interface IPlatformAdapter {
  onTip(handler: (event: TipEvent) => void): void
  onComment(handler: (event: CommentEvent) => void): void
  connect(): Promise<void>
  disconnect(): Promise<void>
}

// Factory
function createDependencies(config: AppConfig) {
  // LLM Client
  const llmClient: ILLMClient = config.useMockLLM
    ? new MockLLMClient()
    : new ClaudeLLMClient(process.env.ANTHROPIC_API_KEY!)

  // Platform Adapter
  const platformAdapter: IPlatformAdapter = config.useMockPlatform
    ? new MockPlatformAdapter()
    : new RealPlatformAdapter({
        youtube: new YouTubeModule(process.env.YOUTUBE_API_KEY!),
        tiktok: new TikTokModule(process.env.TIKTOK_SESSION_ID!),
      })

  return { llmClient, platformAdapter }
}

// Bootstrap
const config = loadConfig()
const { llmClient, platformAdapter } = createDependencies(config)
const agentManager = new AgentManager(llmClient)
const gameLoop = new GameLoop(agentManager, platformAdapter, config)
gameLoop.start()

10. 注意事項 / Notes

Mock モードの制限 / Mock Mode Limitations

  • Mock LLM はキャラクターの個性を完全には再現しない。最終的な会話品質は staging モード(real LLM)で検証すること
  • 時間加速中はアニメーションが高速化されるため、演出の確認には TIME_SCALE=1 で行うこと
  • Mock LLM does not fully reproduce character personalities. Final conversation quality should be verified in staging mode (real LLM)
  • Animations speed up during time acceleration; verify visual effects at TIME_SCALE=1

コスト管理 / Cost Management

  • development モードでは API コスト $0/day(LLM モック使用時)
  • staging モードでも MOCK_PLATFORM=true のため、プラットフォーム API コストは不要
  • 本番前の最終確認のみ production モードを使用すること
  • Development mode: $0/day API cost (when using LLM mock)
  • Staging mode: no platform API costs since MOCK_PLATFORM=true
  • Use production mode only for final pre-launch verification

録画応答の取得方法 / How to Record Responses

staging モードで real LLM を使用中に、応答を自動録画する機能を使用できる。録画データは data/mock/recorded_responses.json に保存される。

bash
# 録画モードで staging を起動
RECORD_LLM_RESPONSES=true pnpm staging

When using real LLM in staging mode, you can enable auto-recording of responses. Recorded data is saved to data/mock/recorded_responses.json.