投げ銭→アイテム変換ロジック / 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 Chat | LifeCoin | 備考 / Notes |
|---|---|---|
| ¥100 | 100 LC | Minimum Super Chat amount |
| ¥200 | 200 LC | |
| ¥500 | 500 LC | |
| ¥1,000 | 1,000 LC | |
| ¥5,000 | 5,000 LC | |
| ¥10,000 | 10,000 LC | |
| ¥50,000 | 50,000 LC | Maximum 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 Gift | Coins | JPY 換算 (≈) | LifeCoin | 備考 / Notes |
|---|---|---|---|---|
| Rose / バラ | 1 | ¥2 | 2 LC | Minimum gift |
| Heart / ハート | 5 | ¥10 | 10 LC | |
| GG | 10 | ¥20 | 20 LC | |
| Finger Heart / ハートサイン | 100 | ¥200 | 200 LC | |
| Drama Queen | 500 | ¥1,000 | 1,000 LC | |
| Lion / ライオン | 29,999 | ¥45,000 | 45,000 LC | |
| Universe / ユニバース | 34,999 | ¥52,500 | 52,500 LC | Maximum 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 combo | 1 coin × 5 = 5 coins | 10 LC (一括変換) |
| Heart × 3 combo | 5 coins × 3 = 15 coins | 30 LC (一括変換) |
| GG × 10 combo | 10 coins × 10 = 100 coins | 200 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:
- 未所持アイテム / Unowned items -- 新規アイテムを優先
- 消耗品 / Consumables (Food, Drink) -- 重複問題が発生しない
- カテゴリ均等化 / Category balancing -- 最も少ないカテゴリのアイテムを優先
- ランダム / Random -- 上記で同率の場合
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.
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.
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 balance3.2 具体例 / Examples
| 投げ銭 / Tip | 選択アイテム / Selected Item | アイテム消費 / Spent | 残高加算 / To Balance |
|---|---|---|---|
| ¥350 → 350 LC | Rice 5kg (300 LC) | 300 LC | 50 LC |
| ¥800 → 800 LC | Recipe book (800 LC) | 800 LC | 0 LC |
| ¥1,300 → 1,300 LC | Hot plate (1,200 LC) | 1,200 LC | 100 LC |
| ¥50 → 50 LC | (アイテムなし / No item) | 0 LC | 50 LC |
| Rose × 3 → 6 LC | (アイテムなし / No item) | 0 LC | 6 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.
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 |
|---|---|---|
| Food | Consumable | 追加可能(スタック)/ Stackable |
| Drink | Consumable | 追加可能(スタック)/ Stackable |
| Daily | Consumable | 追加可能(スタック)/ Stackable |
| Pet (treats/toys) | Consumable | 追加可能(スタック)/ Stackable |
| Interior | Permanent | 代替選択 / Alternative selection |
| Furniture | Permanent | 代替選択 / Alternative selection |
| Appliance | Permanent | 代替選択 / Alternative selection |
| Fashion | Permanent | 代替選択 / Alternative selection |
| Transport | Permanent | 代替選択 / Alternative selection |
| Housing | Permanent | 代替選択 / Alternative selection |
| Event | One-time | 再実行可能 / Replayable |
| Story | One-time | 全額残高へ / Full amount to balance |
| Seasonal | Permanent | 代替選択 / Alternative selection |
4.2 消耗品の重複 / Consumable Duplicates
消耗品はスタック可能。同一アイテムを複数所持できる。
Consumables are stackable. Multiple instances of the same item can be held.
// 消耗品の場合、単純にスタック加算
// 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.
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.
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.
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.
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
]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 Eve | Petカテゴリ優先 / 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 |
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
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
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.
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 |
| E5 | YouTube と TikTok から同時に投げ銭 | タイムスタンプ順で統合キューに入れる / Unified queue ordered by timestamp |
| E6 | TikTok の為替レート更新中に投げ銭 | 処理開始時のレートを使用(ロック)/ Use rate at processing start (lock) |
| E7 | 100,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
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.
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
// アイテムマスター / 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
-- 投げ銭ログテーブル / 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_JPY | 1.5 | TikTok Coins → JPY 変換レート |
COMBO_WINDOW_MS | 5000 | TikTok コンボ集約ウィンドウ (ms) |
TIP_RATE_LIMIT_PER_SEC | 1 | 1秒あたりの即時処理件数上限 |
TIP_QUEUE_MAX_SIZE | 50 | 投げ銭処理キューの最大サイズ |
BALANCE_AUTO_CONVERT | true | 残高が閾値に達したら自動変換するか |
MIN_ITEM_COST | 100 | 最低アイテム価格 (LC) |
DUPLICATE_SEARCH_RANGE | 0.2 | 重複時の代替検索範囲 (±20%) |
MESSAGE_PARSE_ENABLED | true | メッセージからのアイテム指定解析を有効にするか |
10. テストケース / Test Cases
10.1 基本変換テスト / Basic Conversion Tests
| # | Input | Expected Output |
|---|---|---|
| T1 | YouTube ¥100 Super Chat | 100 LC → Cup noodles (100 LC) |
| T2 | YouTube ¥350 Super Chat | 350 LC → Rice 5kg (300 LC) + 50 LC balance |
| T3 | YouTube ¥50,000 Super Chat | 50,000 LC → Baby flag (50,000 LC) |
| T4 | TikTok Rose (1 coin) | 2 LC → 2 LC balance (below min) |
| T5 | TikTok Rose × 50 combo | 100 LC → Cup noodles (100 LC) |
| T6 | TikTok Finger Heart (100 coins) | 200 LC → Dog treats (200 LC) |
| T7 | TikTok Drama Queen (500 coins) | 1,000 LC → Fan (1,000 LC) |
| T8 | TikTok Universe (34,999 coins) | 52,500 LC → Ending (if ≥100k cumulative) or Baby flag (50,000) + 2,500 balance |
10.2 メッセージ解析テスト / Message Parsing Tests
| # | Message | Expected |
|---|---|---|
| M1 | 「こたつ買ってあげて」+ ¥500 | Kotatsu (500 LC) selected |
| M2 | "Buy them dog treats" + ¥200 | Dog treats (200 LC) selected |
| M3 | 「結婚して!」+ ¥500 | Wedding costs 10,000 LC; ¥500 insufficient → 500 LC to balance, show goal |
| M4 | 「おまかせ!」+ ¥1,000 | Auto-match → Fan or Pair mugs (1,000 LC) |
| M5 | 「イヴにプレゼント」+ ¥300 | Pet category priority → Dog ball (300 LC) |
| M6 | "" (no message) + ¥200 | Auto-match → Dog treats or Convenience store sweets (200 LC) |
10.3 重複テスト / Duplicate Tests
| # | Scenario | Expected |
|---|---|---|
| D1 | Cup noodles (consumable) already owned + 100 LC | Stack +1 (consumables stack) |
| D2 | Kotatsu (permanent) already owned + 500 LC | Alternative: Board game or Dog toy set (500 LC) |
| D3 | All 500 LC items owned + 500 LC | Consumable fallback or 500 LC to balance |
| D4 | Wedding (event) already experienced + 10,000 LC | Event replay: Wedding again |
| D5 | Baby flag (story) already triggered + 50,000 LC | 50,000 LC to balance with thank-you message |
10.4 エッジケーステスト / Edge Case Tests
| # | Scenario | Expected |
|---|---|---|
| EC1 | 0 LC conversion (impossible on YouTube, edge on TikTok) | Ignored, logged |
| EC2 | YouTube + TikTok tips at same millisecond | Processed sequentially by unified queue |
| EC3 | 100 rapid-fire Rose gifts from TikTok | Combo aggregated → processed as single conversion |
| EC4 | Message contains NG word + item request | NG filter applied, item request still processed if sanitized message valid |
| EC5 | Balance at 95 LC, then 5 LC TikTok Rose arrives | Balance → 97 LC (still < 100, no auto-convert) |
| EC6 | Game not yet initialized, tip arrives | Held in queue until init complete |