Skip to content

投げ銭→アイテム変換ロジック / Tip-to-Item Conversion Logic

概要 / Overview

投げ銭額に応じた LifeCoin 変換とアイテム出現のマッチングロジックを定義する。 YouTube Super Chat と TikTok Gifts の両プラットフォームからの投げ銭を統一通貨 LifeCoin (LC) に変換し、適切なアイテム/イベントに自動マッチングする。

This document defines the matching logic for LifeCoin conversion and item spawning based on tip amounts. Tips from both YouTube Super Chat and TikTok Gifts are converted into the unified currency LifeCoin (LC), then automatically matched to the appropriate item/event.


1. プラットフォーム別変換ロジック / Platform-Specific Conversion

1.1 YouTube Super Chat → LifeCoin

YouTube Super Chat は日本円 (JPY) で送信される。1:1 の直接変換を採用する。

YouTube Super Chat is sent in JPY. Direct 1:1 conversion is applied.

convertYouTubeTip(amount_jpy: number): number {
  return Math.floor(amount_jpy)  // ¥1 = 1 LC
}
YouTube Super ChatLifeCoin備考 / Notes
¥100100 LCMinimum Super Chat amount
¥200200 LC
¥500500 LC
¥1,0001,000 LC
¥5,0005,000 LC
¥10,00010,000 LC
¥50,00050,000 LCMaximum Super Chat amount

YouTube Super Chat の特性

  • 金額範囲: ¥100 ~ ¥50,000
  • 常に整数の日本円で送られるため、端数は発生しない
  • Super Stickers も同額の Super Chat として扱う

1.2 TikTok Gifts → LifeCoin

TikTok Gifts は TikTok Coins で購入される。Coins の日本円換算レートに基づいて LifeCoin に変換する。

TikTok Gifts are purchased with TikTok Coins. Conversion to LifeCoin is based on the JPY equivalent rate of Coins.

// TikTok Coins → JPY conversion rate (configurable)
const TIKTOK_COIN_TO_JPY = 1.5  // 1 coin ≈ ¥1.5 (2026 estimate)

convertTikTokGift(gift: TikTokGift): number {
  const jpyValue = gift.coinValue * TIKTOK_COIN_TO_JPY
  return Math.floor(jpyValue)  // 端数切り捨て / Floor rounding
}
TikTok GiftCoinsJPY 換算 (≈)LifeCoin備考 / Notes
Rose / バラ1¥22 LCMinimum gift
Heart / ハート5¥1010 LC
GG10¥2020 LC
Finger Heart / ハートサイン100¥200200 LC
Drama Queen500¥1,0001,000 LC
Lion / ライオン29,999¥45,00045,000 LC
Universe / ユニバース34,999¥52,50052,500 LCMaximum gift

為替・レート変動 / Rate Fluctuation

TikTok Coins の購入レートは地域・時期により変動する。TIKTOK_COIN_TO_JPY は管理画面から更新可能とする。レート変更時は既存の LC 残高には影響しない(変換時点のレートで確定)。

TikTok Coin purchase rates vary by region and time. TIKTOK_COIN_TO_JPY should be updatable from the admin panel. Rate changes do not affect existing LC balances (rate is locked at conversion time).

1.3 TikTok ギフトの特殊処理 / TikTok Gift Special Handling

TikTok ギフトには以下の特殊なケースがある。

TikTok gifts have the following special cases.

1.3.1 連続ギフト / Gift Combos

TikTok では同一ギフトを連続送信(コンボ)できる。コンボは合算して1回の変換として扱う。

On TikTok, users can send the same gift consecutively (combo). Combos are aggregated and treated as a single conversion.

// コンボ集約ウィンドウ: 5秒
const COMBO_WINDOW_MS = 5000

handleTikTokGiftCombo(gifts: TikTokGift[]): TipEvent {
  const totalCoins = gifts.reduce((sum, g) => sum + g.coinValue, 0)
  const totalLC = Math.floor(totalCoins * TIKTOK_COIN_TO_JPY)
  return { source: 'tiktok', lc: totalLC, comboCount: gifts.length }
}
例 / Example操作 / Action結果 / Result
Rose × 5 combo1 coin × 5 = 5 coins10 LC (一括変換)
Heart × 3 combo5 coins × 3 = 15 coins30 LC (一括変換)
GG × 10 combo10 coins × 10 = 100 coins200 LC (一括変換)

1.3.2 未知のギフト / Unknown Gifts

TikTok が新しいギフトを追加した場合、coinValue フィールドから自動計算する。ギフト名の表示テーブルに登録がない場合はコイン値からの自動変換を行い、ログに警告を出力する。

When TikTok adds new gifts, auto-calculate from the coinValue field. If the gift name is not in the display table, perform auto-conversion from coin value and log a warning.

convertUnknownGift(coinValue: number): number {
  logger.warn(`Unknown TikTok gift: coinValue=${coinValue}`)
  return Math.floor(coinValue * TIKTOK_COIN_TO_JPY)
}

2. アイテム選定アルゴリズム / Item Selection Algorithm

投げ銭で得た LifeCoin を、ゲーム内アイテム/イベントにマッチングするアルゴリズム。

Algorithm for matching LifeCoin from tips to in-game items/events.

2.1 選定フロー / Selection Flow

┌─────────────────────────────────────────────────────┐
│              投げ銭受信 / Tip Received                │
│              (amount_lc: number)                     │
└──────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Step 1: メッセージ解析 / Parse Message              │
│  投げ銭にメッセージが付いている?                      │
│  Does the tip have a message?                        │
│                                                      │
│  YES → アイテム指定があるか解析                       │
│         Parse for item request                       │
│  NO  → Step 2 へ                                     │
└──────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Step 2: アイテム指定あり? / Item Specified?         │
│                                                      │
│  YES → 指定アイテムのLC ≤ amount_lc?                 │
│         YES → 指定アイテム確定、残額を残高へ           │
│         NO  → 残高不足通知、全額を残高へ              │
│  NO  → Step 3 へ                                     │
└──────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Step 3: 自動マッチング / Auto-Match                 │
│                                                      │
│  (a) 完全一致 / Exact Match                          │
│      amount_lc と同額のアイテムがある?                │
│      YES → そのアイテム確定                           │
│      NO  → (b) へ                                    │
│                                                      │
│  (b) 最近接下位 / Nearest Lower Match                │
│      amount_lc 以下で最も高額なアイテムを選択          │
│      残額 = amount_lc - item.cost → 残高へ           │
│                                                      │
│  (c) 下限未満 / Below Minimum                        │
│      amount_lc < 100 (最低アイテム価格)               │
│      → 全額を残高へ積み立て                           │
└──────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Step 4: 重複チェック / Duplicate Check              │
│                                                      │
│  選択されたアイテムを既に所持している?                 │
│  Already own the selected item?                      │
│                                                      │
│  YES → 重複ハンドリング (§3 参照)                     │
│  NO  → アイテム付与確定                               │
└──────────────────────┬──────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Step 5: 結果出力 / Output Result                    │
│                                                      │
│  {                                                   │
│    item: Item | null,                                │
│    lcSpent: number,                                  │
│    lcToBalance: number,                              │
│    duplicateAction: string | null                    │
│  }                                                   │
└─────────────────────────────────────────────────────┘

2.2 自動マッチングの詳細 / Auto-Match Details

アイテム指定がない場合の自動選定ロジック。

Auto-selection logic when no specific item is requested.

2.2.1 完全一致 / Exact Match

LC 額と完全に一致するアイテムが存在する場合、そのアイテムを選択する。 同額のアイテムが複数存在する場合は、以下の優先順位で選択する:

When an item exists with an exact LC match, select that item. If multiple items share the same LC cost, prioritize in this order:

  1. 未所持アイテム / Unowned items -- 新規アイテムを優先
  2. 消耗品 / Consumables (Food, Drink) -- 重複問題が発生しない
  3. カテゴリ均等化 / Category balancing -- 最も少ないカテゴリのアイテムを優先
  4. ランダム / Random -- 上記で同率の場合
typescript
function selectExactMatch(amount: number, inventory: Inventory): Item | null {
  const candidates = ITEM_MASTER
    .filter(item => item.cost === amount)

  if (candidates.length === 0) return null
  if (candidates.length === 1) return candidates[0]

  // Priority 1: Unowned items
  const unowned = candidates.filter(i => !inventory.has(i.id))
  if (unowned.length === 1) return unowned[0]

  // Priority 2: Consumables (always useful)
  const pool = unowned.length > 0 ? unowned : candidates
  const consumables = pool.filter(i =>
    i.category === 'Food' || i.category === 'Drink'
  )
  if (consumables.length > 0) {
    return consumables[Math.floor(Math.random() * consumables.length)]
  }

  // Priority 3: Category balancing
  const categoryCounts = inventory.getCategoryCounts()
  pool.sort((a, b) =>
    (categoryCounts[a.category] ?? 0) - (categoryCounts[b.category] ?? 0)
  )
  return pool[0]
}

2.2.2 最近接下位マッチ / Nearest Lower Match

完全一致がない場合、投げ銭額以下で最も高額なアイテムを選択する。

When no exact match exists, select the most expensive item at or below the tip amount.

typescript
function selectNearestLower(amount: number, inventory: Inventory): ItemMatch {
  const affordable = ITEM_MASTER
    .filter(item => item.cost <= amount)
    .sort((a, b) => b.cost - a.cost)  // 高額順 / Descending by cost

  if (affordable.length === 0) {
    return { item: null, spent: 0, remainder: amount }
  }

  // 同額の候補が複数ある場合は selectExactMatch と同様のロジック
  // If multiple items share the highest affordable price, use same logic as selectExactMatch
  const maxCost = affordable[0].cost
  const topCandidates = affordable.filter(i => i.cost === maxCost)
  const selected = selectFromCandidates(topCandidates, inventory)

  return {
    item: selected,
    spent: selected.cost,
    remainder: amount - selected.cost
  }
}

2.2.3 下限未満 / Below Minimum Item Price

投げ銭額が最低アイテム価格 (100 LC) 未満の場合。主に TikTok の少額ギフトで発生する。

When tip amount is below the minimum item price (100 LC). Primarily occurs with small TikTok gifts.

typescript
function handleBelowMinimum(amount: number): ConversionResult {
  // 全額を残高に加算
  // Add entire amount to balance
  return {
    item: null,
    spent: 0,
    remainder: amount,
    message: {
      jp: `${amount} LCが残高に追加されました!もう少し貯まるとアイテムが届きます`,
      en: `${amount} LC added to balance! A little more and an item will arrive`
    }
  }
}

3. 端数処理 / Remainder Handling

投げ銭額がアイテム価格と一致しない場合の差額(端数)処理ルール。

Rules for handling the difference (remainder) when the tip amount doesn't match the item price.

3.1 基本ルール / Basic Rule

remainder = tip_lc - item.cost
→ remainder は LifeCoin 残高に加算
→ remainder is added to LifeCoin balance

3.2 具体例 / Examples

投げ銭 / Tip選択アイテム / Selected Itemアイテム消費 / Spent残高加算 / To Balance
¥350 → 350 LCRice 5kg (300 LC)300 LC50 LC
¥800 → 800 LCRecipe book (800 LC)800 LC0 LC
¥1,300 → 1,300 LCHot plate (1,200 LC)1,200 LC100 LC
¥50 → 50 LC(アイテムなし / No item)0 LC50 LC
Rose × 3 → 6 LC(アイテムなし / No item)0 LC6 LC

3.3 残高の利用 / Balance Usage

積み立てられた LifeCoin 残高は以下のルールで処理する。

Accumulated LifeCoin balance is handled with the following rules.

3.3.1 自動アイテム変換 / Auto-Item Conversion

残高が閾値に達したとき、自動でアイテムに変換する。

When balance reaches a threshold, automatically convert to an item.

typescript
const BALANCE_AUTO_CONVERT_THRESHOLDS = [100, 200, 300, 400, 500]

function checkBalanceThreshold(balance: number): Item | null {
  // 残高が最低アイテム価格以上になったら、
  // 最も高額な購入可能アイテムを自動選択
  // When balance reaches minimum item price or above,
  // auto-select the most expensive affordable item
  if (balance >= 100) {
    return selectNearestLower(balance, inventory)
  }
  return null
}

3.3.2 残高表示 / Balance Display

UI に現在の LifeCoin 残高を常時表示する。視聴者が「あと何 LC でアイテムが届く」かわかるようにする。

Always display the current LifeCoin balance in the UI. Let viewers see how many more LC are needed for the next item.

┌──────────────────────────┐
│  💰 LC Balance: 73 LC    │
│  Next item at: 100 LC    │
│  (27 LC to go!)          │
└──────────────────────────┘

4. アイテム重複時の処理 / Duplicate Item Handling

4.1 アイテムカテゴリ別の重複ルール / Duplication Rules by Item Category

アイテムには消耗品永続品の2種類がある。カテゴリにより重複時の挙動が異なる。

Items fall into two types: consumables and permanents. Duplication behavior differs by category.

カテゴリ / Category種別 / Type重複時の挙動 / On Duplicate
FoodConsumable追加可能(スタック)/ Stackable
DrinkConsumable追加可能(スタック)/ Stackable
DailyConsumable追加可能(スタック)/ Stackable
Pet (treats/toys)Consumable追加可能(スタック)/ Stackable
InteriorPermanent代替選択 / Alternative selection
FurniturePermanent代替選択 / Alternative selection
AppliancePermanent代替選択 / Alternative selection
FashionPermanent代替選択 / Alternative selection
TransportPermanent代替選択 / Alternative selection
HousingPermanent代替選択 / Alternative selection
EventOne-time再実行可能 / Replayable
StoryOne-time全額残高へ / Full amount to balance
SeasonalPermanent代替選択 / Alternative selection

4.2 消耗品の重複 / Consumable Duplicates

消耗品はスタック可能。同一アイテムを複数所持できる。

Consumables are stackable. Multiple instances of the same item can be held.

typescript
// 消耗品の場合、単純にスタック加算
// For consumables, simply add to stack
function handleConsumableDuplicate(item: Item, inventory: Inventory): void {
  inventory.addStack(item.id, 1)
}

4.3 永続品の重複(代替選択)/ Permanent Duplicates (Alternative Selection)

既に所持している永続品が選択された場合、代替アイテムを探す。

When a permanent item already owned is selected, search for an alternative.

typescript
function handlePermanentDuplicate(
  originalItem: Item,
  amount: number,
  inventory: Inventory
): ConversionResult {
  // Step 1: 同価格帯の未所持アイテムを探す (±20% 範囲)
  // Step 1: Find unowned items in same price range (±20%)
  const lowerBound = Math.floor(originalItem.cost * 0.8)
  const upperBound = Math.ceil(originalItem.cost * 1.2)

  const alternatives = ITEM_MASTER
    .filter(item =>
      item.cost >= lowerBound &&
      item.cost <= upperBound &&
      item.cost <= amount &&
      !inventory.has(item.id) &&
      item.type === 'permanent'
    )

  if (alternatives.length > 0) {
    const selected = alternatives[Math.floor(Math.random() * alternatives.length)]
    return {
      item: selected,
      spent: selected.cost,
      remainder: amount - selected.cost,
      duplicateAction: 'alternative_selected'
    }
  }

  // Step 2: 代替がない場合、同価格帯の消耗品を探す
  // Step 2: If no alternative, find consumables in same price range
  const consumableAlts = ITEM_MASTER
    .filter(item =>
      item.cost <= amount &&
      item.type === 'consumable'
    )
    .sort((a, b) => b.cost - a.cost)

  if (consumableAlts.length > 0) {
    const selected = consumableAlts[0]
    return {
      item: selected,
      spent: selected.cost,
      remainder: amount - selected.cost,
      duplicateAction: 'consumable_fallback'
    }
  }

  // Step 3: どのアイテムも選べない場合、全額を残高へ
  // Step 3: If no item can be selected, add full amount to balance
  return {
    item: null,
    spent: 0,
    remainder: amount,
    duplicateAction: 'full_to_balance'
  }
}

4.4 イベントアイテムの重複 / Event Item Duplicates

イベント (Event) カテゴリのアイテムは再実行可能とする。同じイベントを何度でも体験できる。

Event category items are replayable. The same event can be experienced multiple times.

typescript
function handleEventDuplicate(item: Item): ConversionResult {
  // イベントは何度でも再実行可能
  // Events can be replayed any number of times
  return {
    item: item,
    spent: item.cost,
    remainder: 0,
    duplicateAction: 'event_replay'
  }
}

4.5 ストーリーアイテムの重複 / Story Item Duplicates

ストーリー (Story) カテゴリのアイテムは一度きり。重複時は全額を残高に加算し、キャラクターが感謝メッセージを表示する。

Story category items are one-time only. On duplicate, the full amount is added to balance and characters display a thank-you message.

typescript
function handleStoryDuplicate(item: Item, amount: number): ConversionResult {
  return {
    item: null,
    spent: 0,
    remainder: amount,
    duplicateAction: 'story_already_unlocked',
    message: {
      jp: `「${item.name_jp}」はもう体験済みです!LCは残高に追加されました`,
      en: `"${item.name_en}" has already been experienced! LC added to balance`
    }
  }
}

5. メッセージ付き投げ銭のアイテム指定解析 / Message-Based Item Request Parsing

5.1 概要 / Overview

YouTube Super Chat や TikTok ギフトにはメッセージを添付できる。メッセージ内にアイテム名や意図が含まれている場合、指定されたアイテムを優先的に選択する。

YouTube Super Chats and TikTok gifts can include messages. When the message contains an item name or intent, prioritize selecting the specified item.

5.2 解析手法 / Parsing Methods

5.2.1 キーワードマッチング / Keyword Matching

アイテムマスターの日本語名・英語名・エイリアスとメッセージを照合する。

Match the message against Japanese names, English names, and aliases from the item master.

typescript
interface ItemAlias {
  itemId: string
  keywords_jp: string[]  // 日本語キーワード
  keywords_en: string[]  // 英語キーワード
}

const ITEM_ALIASES: ItemAlias[] = [
  {
    itemId: 'cup_noodles',
    keywords_jp: ['カップ麺', 'カップラーメン', 'カップヌードル', 'ラーメン'],
    keywords_en: ['cup noodles', 'ramen', 'instant noodles', 'cup noodle']
  },
  {
    itemId: 'kotatsu',
    keywords_jp: ['こたつ', 'コタツ', '炬燵'],
    keywords_en: ['kotatsu', 'heated table']
  },
  {
    itemId: 'dog_treats',
    keywords_jp: ['犬用おやつ', '犬のおやつ', 'イヴにおやつ', 'イブにおやつ', 'わんこおやつ'],
    keywords_en: ['dog treats', 'treats for eve', 'eve treats', 'dog snack']
  },
  {
    itemId: 'wedding',
    keywords_jp: ['結婚式', 'ウェディング', '結婚して'],
    keywords_en: ['wedding', 'marry', 'get married']
  },
  // ... 全アイテムに対して定義
  // ... defined for all items
]
typescript
function parseItemRequest(message: string): string | null {
  const normalizedMsg = message.toLowerCase().trim()

  for (const alias of ITEM_ALIASES) {
    const allKeywords = [...alias.keywords_jp, ...alias.keywords_en]
    for (const keyword of allKeywords) {
      if (normalizedMsg.includes(keyword.toLowerCase())) {
        return alias.itemId
      }
    }
  }

  return null  // アイテム指定なし / No item specified
}

5.2.2 意図パターンマッチング / Intent Pattern Matching

直接的なアイテム名以外にも、意図を表すパターンを認識する。

Recognize intent patterns beyond direct item names.

パターン / Pattern解釈 / Interpretation例 / Example
〇〇買ってあげて / buy them 〇〇アイテム指定 / Item request「お米買ってあげて」→ Rice 5kg
〇〇をプレゼント / give them 〇〇アイテム指定 / Item request「花をプレゼント」→ Single flower
イヴに〇〇 / for EvePetカテゴリ優先 / Prioritize Pet category「イヴにおもちゃ」→ Dog toy set
二人に〇〇 / for the coupleカップル系優先 / Prioritize couple items「二人でデートして」→ Outing event
部屋を〇〇 / room 〇〇Interior/Furniture優先 / Prioritize room items「部屋をおしゃれに」→ Best affordable interior
おまかせ / surprise自動マッチング / Auto-match通常の自動選定 / Normal auto-selection
typescript
const INTENT_PATTERNS = [
  { pattern: /(.+?)(を|)(買って|購入|プレゼント|あげて)/i, type: 'item_request' },
  { pattern: /(buy|get|give)\s+(?:them\s+)?(.+)/i, type: 'item_request' },
  { pattern: /(イヴ|イブ|Eve|えぶ|犬)(に||for)/i, type: 'pet_priority' },
  { pattern: /(二人|ふたり|couple|together|デート|date)/i, type: 'couple_priority' },
  { pattern: /(部屋|room|インテリア|interior|家具|furniture)/i, type: 'room_priority' },
  { pattern: /(おまかせ|お任せ|surprise|anything|なんでも)/i, type: 'auto' },
]

5.3 指定アイテムの購入可否判定 / Affordability Check for Specified Items

typescript
function processItemRequest(
  requestedItemId: string,
  amount: number,
  balance: number,
  inventory: Inventory
): ConversionResult {
  const item = ITEM_MASTER.find(i => i.id === requestedItemId)
  if (!item) {
    // アイテムが見つからない場合は自動マッチングにフォールバック
    // If item not found, fallback to auto-match
    return autoMatch(amount, inventory)
  }

  const totalAvailable = amount + balance

  if (item.cost <= amount) {
    // 投げ銭額だけで購入可能
    // Affordable with tip amount alone
    return {
      item: item,
      spent: item.cost,
      remainder: amount - item.cost,
      message: {
        jp: `リクエスト通り「${item.name_jp}」をお届けします!`,
        en: `As requested, "${item.name_en}" has arrived!`
      }
    }
  } else if (item.cost <= totalAvailable) {
    // 残高と合算で購入可能
    // Affordable with tip + balance combined
    const fromBalance = item.cost - amount
    return {
      item: item,
      spent: item.cost,
      remainder: 0,
      balanceUsed: fromBalance,
      message: {
        jp: `残高と合わせて「${item.name_jp}」をお届けします!`,
        en: `Combined with balance, "${item.name_en}" has arrived!`
      }
    }
  } else {
    // 購入不可 → 全額残高へ、目標表示
    // Cannot afford → add to balance, show target
    return {
      item: null,
      spent: 0,
      remainder: amount,
      message: {
        jp: `「${item.name_jp}」にはあと${item.cost - totalAvailable} LC必要です。LCは残高に追加しました!`,
        en: `${item.cost - totalAvailable} more LC needed for "${item.name_en}". LC added to balance!`
      }
    }
  }
}

6. 変換パイプライン全体 / Complete Conversion Pipeline

6.1 統合処理フロー / Unified Processing Flow

typescript
interface TipEvent {
  platform: 'youtube' | 'tiktok'
  amount: number          // 元の金額 / Original amount
  currency: 'JPY' | 'TIKTOK_COINS'
  message?: string        // メッセージ / Message (optional)
  username: string        // 送信者名 / Sender name
  giftName?: string       // TikTok gift name (TikTok only)
  comboCount?: number     // TikTok combo count (TikTok only)
  timestamp: number       // Unix timestamp
}

interface ConversionResult {
  item: Item | null
  spent: number           // アイテムに使用した LC / LC spent on item
  remainder: number       // 残高に追加する LC / LC added to balance
  balanceUsed?: number    // 残高から使用した LC / LC used from balance
  duplicateAction?: string
  message: { jp: string; en: string }
}

async function processTip(event: TipEvent): Promise<ConversionResult> {
  // Phase 1: LifeCoin 変換 / LifeCoin Conversion
  const lc = convertToLifeCoin(event)

  // Phase 2: ログ記録 / Log the tip
  await logTip(event, lc)

  // Phase 3: 累計更新 & マイルストーンチェック / Update cumulative & check milestones
  const milestoneReached = await updateCumulativeLC(lc)
  if (milestoneReached) {
    await triggerMilestoneBonus(milestoneReached)
  }

  // Phase 4: メッセージ解析 / Parse message
  let requestedItemId: string | null = null
  if (event.message) {
    requestedItemId = parseItemRequest(event.message)
  }

  // Phase 5: アイテム選定 / Item selection
  let result: ConversionResult
  if (requestedItemId) {
    result = processItemRequest(requestedItemId, lc, getBalance(), getInventory())
  } else {
    result = autoMatch(lc, getInventory())
  }

  // Phase 6: ゲーム状態更新 / Update game state
  if (result.item) {
    await applyItem(result.item)
  }
  if (result.remainder > 0) {
    await addToBalance(result.remainder)
  }
  if (result.balanceUsed && result.balanceUsed > 0) {
    await deductFromBalance(result.balanceUsed)
  }

  // Phase 7: 演出トリガー / Trigger visual effects
  await triggerTipEffect(event, result)

  return result
}

function convertToLifeCoin(event: TipEvent): number {
  if (event.platform === 'youtube') {
    return Math.floor(event.amount)  // ¥1 = 1 LC
  } else {
    return Math.floor(event.amount * TIKTOK_COIN_TO_JPY)
  }
}

6.2 レート限制 / Rate Limiting

大量の投げ銭が短時間に発生した場合のレート制限。

Rate limiting when many tips arrive in a short time period.

typescript
const TIP_RATE_LIMIT = {
  maxProcessPerSecond: 1,     // 1秒に1件まで即時処理
  queueOverflow: 'fifo',     // 超過分はキューで順次処理
  maxQueueSize: 50,           // キュー上限
  queueOverflowAction: 'batch' // 上限超過時はバッチ処理
}

7. エッジケースとエラーハンドリング / Edge Cases & Error Handling

7.1 エッジケース一覧 / Edge Case List

#ケース / Case処理 / Handling
E1投げ銭額が 0 LC に変換される無視(ログのみ記録)/ Ignore (log only)
E2全アイテム所持済み + 永続品選択全額を残高に加算 / Add full amount to balance
E3メッセージで存在しないアイテムを指定自動マッチングにフォールバック / Fallback to auto-match
E4同一ユーザーから1秒以内に複数投げ銭キューで順次処理 / Process sequentially via queue
E5YouTube と TikTok から同時に投げ銭タイムスタンプ順で統合キューに入れる / Unified queue ordered by timestamp
E6TikTok の為替レート更新中に投げ銭処理開始時のレートを使用(ロック)/ Use rate at processing start (lock)
E7100,000 LC 超の投げ銭(Ending後)New Game+ でリセット済みなら再度 Ending 可能 / If NG+ reset, Ending available again
E8メッセージが攻撃的/不適切な内容NGワードフィルター適用後にアイテム解析 / Apply NG word filter before item parsing
E9残高が整数を超える端数(TikTok由来)Math.floor() で常に整数化 / Always floor to integer via Math.floor()
E10配信開始直後に大型投げ銭(初期化前)ゲーム状態初期化完了までキューに保持 / Hold in queue until game state initialized

7.2 エラーハンドリング / Error Handling

typescript
async function processTipSafe(event: TipEvent): Promise<ConversionResult> {
  try {
    return await processTip(event)
  } catch (error) {
    logger.error('Tip processing failed', { event, error })

    // フォールバック: 全額を残高に加算し、汎用リアクションを表示
    // Fallback: add full amount to balance, show generic reaction
    const lc = convertToLifeCoin(event)
    await addToBalance(lc)
    await triggerGenericReaction(event)

    return {
      item: null,
      spent: 0,
      remainder: lc,
      message: {
        jp: `${event.username}さんからの応援、ありがとう!${lc} LCを受け取りました!`,
        en: `Thank you for the support from ${event.username}! Received ${lc} LC!`
      }
    }
  }
}

7.3 トランザクション保証 / Transaction Guarantees

投げ銭処理はアトミックに実行する。アイテム付与と残高更新を1トランザクションで行い、途中失敗時はロールバックする。

Tip processing is executed atomically. Item grant and balance update are performed in a single transaction, with rollback on partial failure.

typescript
async function applyConversionResult(result: ConversionResult): Promise<void> {
  const db = getDatabase()

  db.transaction(() => {
    if (result.item) {
      db.run('INSERT INTO inventory (item_id, quantity) VALUES (?, 1) ON CONFLICT(item_id) DO UPDATE SET quantity = quantity + 1',
        [result.item.id])
    }
    if (result.remainder > 0) {
      db.run('UPDATE game_state SET lc_balance = lc_balance + ?', [result.remainder])
    }
    if (result.balanceUsed && result.balanceUsed > 0) {
      db.run('UPDATE game_state SET lc_balance = lc_balance - ?', [result.balanceUsed])
    }
    db.run('UPDATE game_state SET total_lc_received = total_lc_received + ?',
      [result.spent + result.remainder])
  })()
}

8. データモデル / Data Models

8.1 型定義 / Type Definitions

typescript
// アイテムマスター / Item Master
interface ItemMaster {
  id: string
  name_jp: string
  name_en: string
  cost: number            // LifeCoin price
  category: ItemCategory
  type: 'consumable' | 'permanent' | 'event' | 'story'
  effect: Record<string, number>
  tier: 1 | 2 | 3 | 4 | 5
  keywords_jp: string[]   // メッセージ解析用 / For message parsing
  keywords_en: string[]   // メッセージ解析用 / For message parsing
}

type ItemCategory =
  | 'Food' | 'Drink' | 'Daily' | 'Pet'     // Typically consumable
  | 'Interior' | 'Furniture' | 'Appliance'  // Permanent
  | 'Fashion' | 'Transport' | 'Housing'     // Permanent
  | 'Entertainment' | 'Event' | 'Story'     // Event/Story
  | 'Seasonal' | 'Character'               // Special

// 投げ銭ログ / Tip Log
interface TipLog {
  id: number
  platform: 'youtube' | 'tiktok'
  username: string
  originalAmount: number
  originalCurrency: string
  lifeCoin: number
  conversionRate: number
  itemId: string | null
  lcSpent: number
  lcToBalance: number
  message: string | null
  timestamp: number
}

// ゲーム状態(通貨関連)/ Game State (currency-related)
interface GameCurrencyState {
  lcBalance: number           // 現在の残高 / Current balance
  totalLcReceived: number     // 累計受取 LC / Total LC received
  totalTipCount: number       // 累計投げ銭回数 / Total tip count
  lastTipTimestamp: number    // 最後の投げ銭時刻 / Last tip timestamp
  tiktokCoinRate: number      // 現在のTikTokレート / Current TikTok rate
}

8.2 SQLite スキーマ / SQLite Schema

sql
-- 投げ銭ログテーブル / Tip log table
CREATE TABLE tip_logs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  platform TEXT NOT NULL CHECK (platform IN ('youtube', 'tiktok')),
  username TEXT NOT NULL,
  original_amount REAL NOT NULL,
  original_currency TEXT NOT NULL,
  life_coin INTEGER NOT NULL,
  conversion_rate REAL NOT NULL DEFAULT 1.0,
  item_id TEXT,
  lc_spent INTEGER NOT NULL DEFAULT 0,
  lc_to_balance INTEGER NOT NULL DEFAULT 0,
  message TEXT,
  duplicate_action TEXT,
  created_at INTEGER NOT NULL DEFAULT (unixepoch())
);

-- インベントリテーブル / Inventory table
CREATE TABLE inventory (
  item_id TEXT PRIMARY KEY,
  quantity INTEGER NOT NULL DEFAULT 1,
  first_acquired_at INTEGER NOT NULL DEFAULT (unixepoch()),
  last_acquired_at INTEGER NOT NULL DEFAULT (unixepoch())
);

-- 通貨状態テーブル / Currency state table
CREATE TABLE currency_state (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  lc_balance INTEGER NOT NULL DEFAULT 0,
  total_lc_received INTEGER NOT NULL DEFAULT 0,
  total_tip_count INTEGER NOT NULL DEFAULT 0,
  last_tip_at INTEGER,
  tiktok_coin_rate REAL NOT NULL DEFAULT 1.5
);

-- インデックス / Indexes
CREATE INDEX idx_tip_logs_platform ON tip_logs(platform);
CREATE INDEX idx_tip_logs_username ON tip_logs(username);
CREATE INDEX idx_tip_logs_created_at ON tip_logs(created_at);

9. 設定値一覧 / Configuration Values

運用時に調整可能なパラメータ一覧。

List of parameters adjustable during operation.

パラメータ / Parameterデフォルト値 / Default説明 / Description
TIKTOK_COIN_TO_JPY1.5TikTok Coins → JPY 変換レート
COMBO_WINDOW_MS5000TikTok コンボ集約ウィンドウ (ms)
TIP_RATE_LIMIT_PER_SEC11秒あたりの即時処理件数上限
TIP_QUEUE_MAX_SIZE50投げ銭処理キューの最大サイズ
BALANCE_AUTO_CONVERTtrue残高が閾値に達したら自動変換するか
MIN_ITEM_COST100最低アイテム価格 (LC)
DUPLICATE_SEARCH_RANGE0.2重複時の代替検索範囲 (±20%)
MESSAGE_PARSE_ENABLEDtrueメッセージからのアイテム指定解析を有効にするか

10. テストケース / Test Cases

10.1 基本変換テスト / Basic Conversion Tests

#InputExpected Output
T1YouTube ¥100 Super Chat100 LC → Cup noodles (100 LC)
T2YouTube ¥350 Super Chat350 LC → Rice 5kg (300 LC) + 50 LC balance
T3YouTube ¥50,000 Super Chat50,000 LC → Baby flag (50,000 LC)
T4TikTok Rose (1 coin)2 LC → 2 LC balance (below min)
T5TikTok Rose × 50 combo100 LC → Cup noodles (100 LC)
T6TikTok Finger Heart (100 coins)200 LC → Dog treats (200 LC)
T7TikTok Drama Queen (500 coins)1,000 LC → Fan (1,000 LC)
T8TikTok Universe (34,999 coins)52,500 LC → Ending (if ≥100k cumulative) or Baby flag (50,000) + 2,500 balance

10.2 メッセージ解析テスト / Message Parsing Tests

#MessageExpected
M1「こたつ買ってあげて」+ ¥500Kotatsu (500 LC) selected
M2"Buy them dog treats" + ¥200Dog treats (200 LC) selected
M3「結婚して!」+ ¥500Wedding costs 10,000 LC; ¥500 insufficient → 500 LC to balance, show goal
M4「おまかせ!」+ ¥1,000Auto-match → Fan or Pair mugs (1,000 LC)
M5「イヴにプレゼント」+ ¥300Pet category priority → Dog ball (300 LC)
M6"" (no message) + ¥200Auto-match → Dog treats or Convenience store sweets (200 LC)

10.3 重複テスト / Duplicate Tests

#ScenarioExpected
D1Cup noodles (consumable) already owned + 100 LCStack +1 (consumables stack)
D2Kotatsu (permanent) already owned + 500 LCAlternative: Board game or Dog toy set (500 LC)
D3All 500 LC items owned + 500 LCConsumable fallback or 500 LC to balance
D4Wedding (event) already experienced + 10,000 LCEvent replay: Wedding again
D5Baby flag (story) already triggered + 50,000 LC50,000 LC to balance with thank-you message

10.4 エッジケーステスト / Edge Case Tests

#ScenarioExpected
EC10 LC conversion (impossible on YouTube, edge on TikTok)Ignored, logged
EC2YouTube + TikTok tips at same millisecondProcessed sequentially by unified queue
EC3100 rapid-fire Rose gifts from TikTokCombo aggregated → processed as single conversion
EC4Message contains NG word + item requestNG filter applied, item request still processed if sanitized message valid
EC5Balance at 95 LC, then 5 LC TikTok Rose arrivesBalance → 97 LC (still < 100, no auto-convert)
EC6Game not yet initialized, tip arrivesHeld in queue until init complete