{ const { attr, tier } = answer; // Determine user target value let userTarget; let cityValue; let weight; let label; if (attr === "political") { userTarget = POL_NUMERIC[tier] ?? 5; cityValue = cityPoliticalNumeric(city); weight = (tier === "polProg" || tier === "polStrict") ? 2.0 : 1.0; label = "Politik"; } else if (attr === "soul") { // Soul: average distance across all soul attrs using same tier target const rawTarget = TIER_TARGET[tier] ?? 5; let totalDist = 0; let counted = 0; SOUL_ATTRS.forEach(a => { if (city[a] === undefined) return; let cv = city[a]; // For inverted attrs within soul, flip the target const effectiveTarget = INVERTED_ATTRS.includes(a) ? (10.5 - rawTarget) : rawTarget; totalDist += Math.abs(effectiveTarget - cv); counted++; }); const avgDist = counted > 0 ? totalDist / counted : 5; weight = (tier === "elite" || tier === "hardcore") ? 2.0 : 1.0; totalWeightedDistance += avgDist * weight; maxWeightedDistance += 8.5 * weight; const pct = Math.round((1 - (avgDist * weight) / (8.5 * weight)) * 100); if (pct >= 70) matchDetails.push({ attr: "soul", pct, label: "🎭 City Vibe Match" }); return; } else { const rawTarget = TIER_TARGET[tier]; if (rawTarget === undefined) return; // For inverted attrs, flip: user answering A (elite) on pulse WANTS low pulse city userTarget = INVERTED_ATTRS.includes(attr) ? (10.5 - rawTarget) : rawTarget; cityValue = city[attr]; if (cityValue === undefined) return; weight = (tier === "elite" || tier === "hardcore") ? 2.0 : 1.0; label = attr; } const distance = Math.abs(userTarget - cityValue); const weighted = distance * weight; totalWeightedDistance += weighted; maxWeightedDistance += 8.5 * weight; // max possible distance per attr // Track good matches for "why this city" const pct = Math.round((1 - weighted / (8.5 * weight)) * 100); if (pct >= 70) matchDetails.push({ attr, pct, label }); }); // Warlord gate: cities with 3+ extreme danger attrs require hardcore intent const dangerCount = ["laptopSafety","internet","walk"].filter(a => city[a] <= 2).length; const hardcoreCount = answers.filter(a => a.tier === "hardcore" || a.tier === "polStrict" ).length; if (dangerCount >= 3 && hardcoreCount < 2) { totalWeightedDistance += 25; // penalty = large distance injection } // displayScore: 0-100 compatibility % const usedMax = maxWeightedDistance || MAX_POSSIBLE_DISTANCE; const displayScore = Math.max(0, Math.round((1 - totalWeightedDistance / usedMax) * 100)); return { score: -totalWeightedDistance, // negative distance = higher is better (for sorting) displayScore, // 0-100% for UI matchDetails: matchDetails .sort((a, b) => b.pct - a.pct) .slice(0, 3), totalWeightedDistance, }; } // ─── DEBUG: Simulate pure A-run and pure D-run ──────────────────────────────── // (runs once at module load, logs to console) function debugRuns() { const ATTRS_FOR_DEBUG = [ {attr:"laptopSafety", tier:"elite"}, {attr:"pulse", tier:"elite"}, {attr:"thirst", tier:"elite"}, {attr:"wealth", tier:"elite"}, {attr:"redTape", tier:"elite"}, {attr:"air", tier:"elite"}, {attr:"internet", tier:"elite"}, {attr:"political", tier:"polProg"}, {attr:"expatRatio", tier:"elite"}, {attr:"walk", tier:"elite"}, {attr:"soul", tier:"elite"}, ]; const D_ATTRS = ATTRS_FOR_DEBUG.map(a => ({ ...a, tier: a.tier === "polProg" ? "polStrict" : "hardcore" })); const scores = (answers) => cityData.map(city => ({ city: city.city, ...scoreCity(city, answers), })).sort((a,b) => b.displayScore - a.displayScore).slice(0,3); console.log("=== DEBUG: Pure A-Run (elite everywhere) ==="); scores(ATTRS_FOR_DEBUG).forEach((c,i) => console.log(` #${i+1} ${c.city}: ${c.displayScore}% (dist: ${c.totalWeightedDistance?.toFixed(1)})`) ); console.log("=== DEBUG: Pure D-Run (hardcore everywhere) ==="); scores(D_ATTRS).forEach((c,i) => console.log(` #${i+1} ${c.city}: ${c.displayScore}% (dist: ${c.totalWeightedDistance?.toFixed(1)})`) ); } setTimeout(debugRuns, 500); // ─── ANTI-REPEAT QUESTION SYSTEM ───────────────────────────────────────────── // 12 attribute groups → 1 random question each per session = 12 Qs per run. // Per-group used-queue resets independently when exhausted. const ATTR_GROUPS = [ "laptopSafety", // Q1–5 (5 questions) "pulse", // Q6–10 (5 questions, inverted tiers) "thirst", // Q11–15 (5 questions) "wealth", // Q16–20 (5 questions) "redTape", // Q21–25 (5 questions, inverted) "air", // Q26–30 (5 questions) "internet", // Q31–35 (5 questions) "political", // Q36–40 (5 questions, 4-tier numeric) "expatRatio", // Q41–43 (3 questions) "walk", // Q44–49 (6 questions, incl. third place) "soul", // Q50–60 (11 questions, composite scoring) ]; const sessionUsed = {}; ATTR_GROUPS.forEach(a => { sessionUsed[a] = []; }); function getNextQuestions() { const picked = []; ATTR_GROUPS.forEach(attr => { const groupPool = QUESTIONS_POOL.filter(q => q.attr === attr); if (groupPool.length === 0) return; if (sessionUsed[attr].length >= groupPool.length) sessionUsed[attr] = []; const available = groupPool.filter(q => !sessionUsed[attr].includes(q.id)); const chosen = available[Math.floor(Math.random() * available.length)]; if (chosen) { sessionUsed[attr].push(chosen.id); picked.push(chosen); } }); return picked.sort(() => Math.random() - 0.5); } function totalUsedCount() { return Object.values(sessionUsed).reduce((sum, arr) => sum + arr.length, 0); } const TOTAL_POOL_SIZE = QUESTIONS_POOL.length; const ATTR_LABELS = { laptopSafety: "🔒 Safety & Security", thirst: "💘 Dating & Social Life", pulse: "⚡ City Energy & Chaos", walk: "👟 Walkability", wealth: "💸 Purchasing Power", redTape: "📋 Bureaucracy Level", air: "☀️ Air & Sunlight", internet: "🌐 Internet Reliability", expatRatio: "🌍 Expat Community", political: "🗳️ Political Vibe", }; const FLAG_MAP = { "Germany":"🇩🇪","Portugal":"🇵🇹","USA":"🇺🇸","Japan":"🇯🇵","Netherlands":"🇳🇱", "France":"🇫🇷","UK":"🇬🇧","Spain":"🇪🇸","Italy":"🇮🇹","Austria":"🇦🇹", "Switzerland":"🇨🇭","Denmark":"🇩🇰","Sweden":"🇸🇪","Czech Republic":"🇨🇿", "Poland":"🇵🇱","Hungary":"🇭🇺","Greece":"🇬🇷","Singapore":"🇸🇬","Australia":"🇦🇺", "UAE":"🇦🇪","Canada":"🇨🇦","South Korea":"🇰🇷","China":"🇨🇳","Brazil":"🇧🇷", "Mexico":"🇲🇽","Turkey":"🇹🇷","Israel":"🇮🇱","Taiwan":"🇹🇼","India":"🇮🇳", "South Africa":"🇿🇦","Egypt":"🇪🇬","Malaysia":"🇲🇾","Iceland":"🇮🇸","Belgium":"🇧🇪", "Nigeria":"🇳🇬","Kenya":"🇰🇪","Indonesia":"🇮🇩","Colombia":"🇨🇴","Vietnam":"🇻🇳", "Bulgaria":"🇧🇬","Thailand":"🇹🇭","Estonia":"🇪🇪","Croatia":"🇭🇷","Georgia":"🇬🇪", "Bosnia":"🇧🇦","North Macedonia":"🇲🇰","Malta":"🇲🇹","Rwanda":"🇷🇼","Cambodia":"🇰🇭", "Serbia":"🇷🇸","Armenia":"🇦🇲","Ukraine":"🇺🇦","Uzbekistan":"🇺🇿","Ethiopia":"🇪🇹", "Senegal":"🇸🇳","Cuba":"🇨🇺","Bolivia":"🇧🇴","Paraguay":"🇵🇾","Uruguay":"🇺🇾", "Chile":"🇨🇱","Peru":"🇵🇪","Ecuador":"🇪🇨","Azerbaijan":"🇦🇿","Pakistan":"🇵🇰", "Myanmar":"🇲🇲","Mongolia":"🇲🇳","Nepal":"🇳🇵","Bangladesh":"🇧🇩","Saudi Arabia":"🇸🇦", "Lebanon":"🇱🇧","Tunisia":"🇹🇳","Morocco":"🇲🇦","Zimbabwe":"🇿🇼","Madagascar":"🇲🇬", "Angola":"🇦🇴","Kyrgyzstan":"🇰🇬","Tajikistan":"🇹🇯","Chad":"🇹🇩","Afghanistan":"🇦🇫", "North Korea":"🇰🇵","Somalia":"🇸🇴","Venezuela":"🇻🇪","Haiti":"🇭🇹","DRC":"🇨🇩", "Libya":"🇱🇾","Yemen":"🇾🇪","Syria":"🇸🇾","CAR":"🇨🇫","Guatemala":"🇬🇹", "Montenegro":"🇲🇪","Guyana":"🇬🇾","Suriname":"🇸🇷","Namibia":"🇳🇦","Tanzania":"🇹🇿", "Sri Lanka":"🇱🇰","Ghana":"🇬🇭","Kazakhstan":"🇰🇿","Argentina":"🇦🇷","Romania":"🇷🇴", "Samoa":"🇼🇸","Costa Rica":"🇨🇷","Laos":"🇱🇦", }; // ─── APP ────────────────────────────────────────────────────────────────────── export default function CitySoulmate() { const [phase, setPhase] = useState("intro"); const [questions, setQuestions] = useState([]); const [currentQ, setCurrentQ] = useState(0); const [answers, setAnswers] = useState([]); const [result, setResult] = useState(null); const [showWhy, setShowWhy] = useState(false); const [animIn, setAnimIn] = useState(true); const [selectedOpt, setSelectedOpt] = useState(null); const [usedCount, setUsedCount] = useState(0); const startQuiz = () => { const qs = getNextQuestions(10); setQuestions(qs); setCurrentQ(0); setAnswers([]); setResult(null); setShowWhy(false); setSelectedOpt(null); setAnimIn(true); setUsedCount(totalUsedCount()); setPhase("quiz"); }; const handleAnswer = (opt, q) => { setSelectedOpt(opt.label); setTimeout(() => { const newAnswer = { attr: q.attr, tier: opt.tier, questionText: q.text.slice(0, 45) + "…", }; const newAnswers = [...answers, newAnswer]; if (currentQ + 1 >= questions.length) { const scored = cityData.map(city => ({ ...city, ...scoreCity(city, newAnswers), })); scored.sort((a, b) => b.score - a.score); // Weighted random Top-5 selection const top5 = scored.slice(0, 5); const W = [0.40, 0.25, 0.20, 0.10, 0.05]; const rand = Math.random(); let cumul = 0, chosen = top5[0]; for (let i = 0; i < top5.length; i++) { cumul += W[i]; if (rand < cumul) { chosen = top5[i]; break; } } setResult(chosen); setPhase("result"); } else { setAnimIn(false); setTimeout(() => { setAnswers(newAnswers); setCurrentQ(currentQ + 1); setSelectedOpt(null); setAnimIn(true); }, 180); } }, 350); }; const progress = questions.length ? (currentQ / questions.length) * 100 : 0; return (
The world's most brutally honest city matcher
10 QUESTIONS · 4 OPTIONS · WEIGHTED SCORING · NO FILTER