開発用モック・テストモード設計 / 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
| Mode | NODE_ENV | LLM | Platform | Time | DB | Use Case |
|---|---|---|---|---|---|---|
| development | development | Mock (default) | Mock | Accelerated | In-memory SQLite | ローカル開発 / Local dev |
| staging | staging | Real (Claude Haiku 4.5) | Mock | Normal | File SQLite | 結合テスト / Integration test |
| production | production | Real (Claude Haiku 4.5) | Real (YT + TikTok) | Normal | File SQLite | 本番配信 / Live stream |
1.2 環境変数 / Environment Variables
# .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=xxxxx1.3 モード判定ロジック / Mode Detection Logic
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
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
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
// 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
| Strategy | Description | Use 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
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
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
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 |
|---|---|---|
1 | 10 minutes (600 sec) | 本番・ステージング / Production, staging |
6 | 100 seconds | スロー開発確認 / Slow dev check |
60 | 10 seconds | 高速開発 / Fast development |
600 | 1 second | 超高速テスト / Ultra-fast automated test |
4.3 実装方針 / Implementation Approach
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.
// ステータス変化は 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 |
|---|---|---|---|
| S1 | tip-reaction | 投げ銭の即時反応を検証 / Verify instant tip reactions | 各金額帯の投げ銭を送信、キャラ反応・アイテム付与を確認 |
| S2 | comment-response | コメント応答を検証 / Verify comment responses | JP/EN コメントを送信、10分サイクルで応答を確認 |
| S3 | status-zero | ステータス0時の挙動を検証 / Verify behavior when status=0 | hunger=0 まで放置、キャラの緊急行動を確認 |
| S4 | argument-makeup | 喧嘩→仲直りフローを検証 / Verify argument → makeup flow | love値を低下させ、喧嘩発生→自動仲直りを確認 |
| S5 | duplicate-item | 重複アイテム購入を検証 / Verify duplicate item purchase | 同一アイテムを2回購入、代替選択・スタック動作を確認 |
5.2 S1: 投げ銭リアクション / Tip Reaction
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
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
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
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
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
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
# 開発モード(モック有効 + 時間加速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 dev7.2 ステージングモード起動 / Start Staging Mode
# ステージングモード(実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 dev7.3 Mock Web UI へのアクセス / Access Mock Web UI
# 開発モードまたはステージングモード起動後
# After starting development or staging mode
# Mock Control UI
open http://localhost:3001/mock
# Game screen (normal frontend)
open http://localhost:30007.4 CLI からのシナリオ実行 / Run Scenarios from CLI
# 特定のシナリオを実行 / 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-zero7.5 package.json スクリプト / package.json Scripts
{
"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 loader9. 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
// 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 に保存される。
# 録画モードで staging を起動
RECORD_LLM_RESPONSES=true pnpm stagingWhen using real LLM in staging mode, you can enable auto-recording of responses. Recorded data is saved to data/mock/recorded_responses.json.