ponshu-room-lite/lib/services/mbti_compatibility_service....

248 lines
8.7 KiB
Dart
Raw Permalink Normal View History

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<String, Map<String, double>> get flavorPreferences => _flavorPreferences;
/// 16タイプ別の五味チャート好み1-5スケール
/// { aroma, sweetness, acidity, bitterness, body }
static const Map<String, Map<String, double>> _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<String, List<String>> _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 = <String>[];
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 = <String>[];
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<String> 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 : '';
}