import '../models/sake_item.dart'; import 'mbti_types.dart'; /// MBTIタイプと日本酒の相性を計算するサービス /// /// ユーザーの公式MBTIタイプと、日本酒のタグ・特定条件・五味チャートを照合して /// 相性スコア(0.0〜1.0)と理由を返す class MBTICompatibilityService { /// 16タイプ別の五味チャート好み(1-5スケール)を公開 /// { aroma, sweetness, acidity, bitterness, body } static Map> get flavorPreferences => _flavorPreferences; /// 16タイプ別の五味チャート好み(1-5スケール) /// { aroma, sweetness, acidity, bitterness, body } static const Map> _flavorPreferences = { // 分析家グループ(NT型)- 論理的、複雑な味わい好み 'INTJ': {'aroma': 4.5, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.0, 'body': 4.0}, 'INTP': {'aroma': 4.0, 'sweetness': 2.5, 'acidity': 4.0, 'bitterness': 3.5, 'body': 3.0}, 'ENTJ': {'aroma': 4.5, 'sweetness': 2.0, 'acidity': 3.0, 'bitterness': 4.5, 'body': 4.5}, 'ENTP': {'aroma': 4.0, 'sweetness': 3.5, 'acidity': 4.5, 'bitterness': 3.0, 'body': 3.5}, // 外交官グループ(NF型)- 感性的、華やかで甘め好み 'INFJ': {'aroma': 4.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 2.5, 'body': 4.0}, 'INFP': {'aroma': 5.0, 'sweetness': 4.0, 'acidity': 2.5, 'bitterness': 2.0, 'body': 3.0}, 'ENFJ': {'aroma': 4.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 2.5, 'body': 3.5}, 'ENFP': {'aroma': 5.0, 'sweetness': 4.5, 'acidity': 3.5, 'bitterness': 2.0, 'body': 3.0}, // 番人グループ(SJ型)- 伝統的、バランス重視 'ISTJ': {'aroma': 2.5, 'sweetness': 2.5, 'acidity': 3.0, 'bitterness': 4.0, 'body': 4.0}, 'ISFJ': {'aroma': 3.0, 'sweetness': 4.0, 'acidity': 2.5, 'bitterness': 2.5, 'body': 3.5}, 'ESTJ': {'aroma': 2.5, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.5, 'body': 4.0}, 'ESFJ': {'aroma': 3.5, 'sweetness': 3.5, 'acidity': 3.0, 'bitterness': 3.0, 'body': 3.5}, // 探検家グループ(SP型)- 冒険的、インパクト重視 'ISTP': {'aroma': 3.0, 'sweetness': 2.0, 'acidity': 3.5, 'bitterness': 4.0, 'body': 5.0}, 'ISFP': {'aroma': 4.5, 'sweetness': 4.0, 'acidity': 3.5, 'bitterness': 2.0, 'body': 3.0}, 'ESTP': {'aroma': 3.0, 'sweetness': 2.0, 'acidity': 4.0, 'bitterness': 4.5, 'body': 5.0}, 'ESFP': {'aroma': 5.0, 'sweetness': 4.5, 'acidity': 4.0, 'bitterness': 2.0, 'body': 2.5}, }; /// 16タイプ別の酒種好み(複数条件) static const Map> _sakeTypePreferences = { 'INTJ': ['大吟醸', '純米大吟醸', '斗瓶'], 'INTP': ['実験', '低アルコール', '酵母'], 'ENTJ': ['大吟醸', '純米大吟醸', '金賞', 'プレミアム'], 'ENTP': ['貴醸酒', '古酒', '熟成'], 'INFJ': ['古酒', '熟成', '秘蔵'], 'INFP': ['春', '夏', '秋', '冬', '限定', '花', '桜'], 'ENFJ': ['純米吟醸', '吟醸'], 'ENFP': ['限定', 'コラボ', '可愛い'], 'ISTJ': ['生酛', '山廃', '純米', '本醸造'], 'ISFJ': ['特別純米', '純米'], 'ESTJ': ['辛口', '本醸造', '淡麗'], 'ESFJ': ['純米', '食中酒', '地酒'], 'ISTP': ['無濾過', '生原酒', '原酒'], 'ISFP': ['スパークリング', '微発泡', 'にごり'], 'ESTP': ['原酒', '高アルコール', '超辛口'], 'ESFP': ['スパークリング', 'にごり', 'ピンク'], }; /// 相性スコアを計算(0.0〜1.0) static CompatibilityResult calculateCompatibility(String? mbtiType, SakeItem sake) { if (mbtiType == null || mbtiType.isEmpty) { return CompatibilityResult.noMbti(); } final typeProfile = MBTIType.types[mbtiType]; if (typeProfile == null) { return CompatibilityResult.noMbti(); } double score = 0.0; final reasons = []; double maxPossibleScore = 0.0; // 1. 五味チャートマッチング(最大40点) final flavorPref = _flavorPreferences[mbtiType]; final tasteStats = sake.hiddenSpecs.tasteStats; if (flavorPref != null && tasteStats.isNotEmpty) { maxPossibleScore += 40; double flavorScore = 0; int matchedAxes = 0; for (final axis in ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']) { final pref = flavorPref[axis] ?? 3.0; final actual = (tasteStats[axis] ?? 3).toDouble(); final diff = (pref - actual).abs(); // 差が1以下なら高スコア、2以下なら中スコア if (diff <= 1.0) { flavorScore += 8; // 8点/軸 matchedAxes++; } else if (diff <= 2.0) { flavorScore += 4; // 4点/軸 } } score += flavorScore; if (matchedAxes >= 3) { reasons.add('五味チャートが好みに合致'); } else if (matchedAxes >= 2) { reasons.add('味わいのバランスが良好'); } } else { // 五味データがない場合はニュートラル加点 maxPossibleScore += 40; score += 20; } // 2. タグマッチング(最大30点) final sakeTags = sake.hiddenSpecs.flavorTags.map((t) => t.toLowerCase()).toList(); final favoriteTags = typeProfile.favorableTags.map((t) => t.toLowerCase()).toList(); maxPossibleScore += 30; int tagMatchCount = 0; final matchedTags = []; for (final tag in sakeTags) { for (final fav in favoriteTags) { if (tag.contains(fav) || fav.contains(tag)) { tagMatchCount++; matchedTags.add(fav); break; } } } // 最大3タグまでカウント(各10点) score += (tagMatchCount.clamp(0, 3) * 10); if (matchedTags.isNotEmpty) { reasons.add('「${matchedTags.first}」にマッチ'); } // 3. 酒種マッチング(最大20点) final sakeType = sake.hiddenSpecs.type?.toLowerCase() ?? ''; final sakeName = sake.displayData.displayName.toLowerCase(); final typePref = _sakeTypePreferences[mbtiType] ?? []; maxPossibleScore += 20; bool typeMatched = false; for (final pref in typePref) { if (sakeType.contains(pref.toLowerCase()) || sakeName.contains(pref.toLowerCase())) { score += 20; typeMatched = true; reasons.add('おすすめ酒種にマッチ'); break; } } // 酒種マッチがなくてもニュートラル加点 if (!typeMatched) { score += 10; } // 4. 評価補正(最大10点) maxPossibleScore += 10; final rating = sake.displayData.rating ?? 3.0; if (rating >= 4.0) { score += 10; if (reasons.length < 3) reasons.add('高評価銘柄'); } else if (rating >= 3.0) { score += 5; } // 最終スコア計算(0.0〜1.0に正規化) final normalizedScore = maxPossibleScore > 0 ? (score / maxPossibleScore).clamp(0.3, 1.0) : 0.5; // 理由がない場合のデフォルト if (reasons.isEmpty) { reasons.add('バランスの良い一本'); } return CompatibilityResult( score: normalizedScore, mbtiType: mbtiType, reasons: reasons, ); } /// 相性パーセンテージを取得(表示用) static int getCompatibilityPercent(String? mbtiType, SakeItem sake) { final result = calculateCompatibility(mbtiType, sake); return (result.score * 100).round(); } } /// 相性計算結果 class CompatibilityResult { final double score; final String? mbtiType; final List reasons; final bool hasResult; CompatibilityResult({ required this.score, required this.mbtiType, required this.reasons, this.hasResult = true, }); factory CompatibilityResult.noMbti() { return CompatibilityResult( score: 0.0, mbtiType: null, reasons: [], hasResult: false, ); } /// パーセンテージ表示 int get percent => (score * 100).round(); /// 星評価(0〜5) int get starRating { if (score >= 0.9) return 5; if (score >= 0.75) return 4; if (score >= 0.6) return 3; if (score >= 0.45) return 2; return 1; } /// 星評価文字列 String get starDisplay { const filledStar = '★'; const emptyStar = '☆'; return filledStar * starRating + emptyStar * (5 - starRating); } /// 相性レベル String get level { if (score >= 0.85) return '最高'; if (score >= 0.70) return '良好'; if (score >= 0.55) return '普通'; if (score >= 0.40) return 'まあまあ'; return 'お試し'; } /// 主な理由(1つのみ) String get primaryReason => reasons.isNotEmpty ? reasons.first : ''; }