import { useMutation, useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { buildGekanatorQuestions, fetchGekanatorPosts, saveGekanatorGame } from '@/lib/gekanator' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import type { FC } from 'react' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned' type AnswerOption = { label: string value: GekanatorAnswerValue } type Confidence = { post: Post score: number percent: number } type AnswerPreview = { answer: GekanatorAnswerValue top: Confidence | null candidateCount: number effectiveCandidates: number entropy: number } type GameSnapshot = { phase: Phase scores: Map answers: GekanatorAnswerLog[] askedIds: Set candidateIds: Set | null softenedQuestionIds: Set questionBank: GekanatorQuestion[] search: string rejectedPostIds: Set lastGuessQuestionCount: number lastRejectedGuessId: number | null activeGuessId: number | null } const answerOptions: AnswerOption[] = [ { label: 'はい', value: 'yes' }, { label: 'いいえ', value: 'no' }, { label: '部分的にそう', value: 'partial' }, { label: 'たぶんいいえ', value: 'probably_no' }, { label: 'わからない', value: 'unknown' }] const questionsBetweenGuesses = 25 const hardMaxQuestions = 80 const softenedAnswerWeight = .35 const confidenceTemperature = 6 const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => { switch (answer) { case 'yes': return matched ? 4 : -4 case 'no': return matched ? -4 : 4 case 'partial': return matched ? 2 : -1 case 'probably_no': return matched ? -2 : 2 case 'unknown': return 0 } } const answerWeightFor = ( questionId: string, softenedQuestionIds: Set, ): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 const questionDifficulty = (question: GekanatorQuestion): number => { if (question.id === 'structure:many-tags') return 6 if (question.id.startsWith ('date:')) return 5 if (question.id === 'title:long' || question.id === 'title:ascii') return 4 if (question.kind === 'source') return 4 if (question.kind === 'tag') return 3 if (question.kind === 'title' || question.kind === 'structure') return 2 return 1 } const recalculateScores = ({ posts, questions, answers, softenedQuestionIds, }: { posts: Post[] questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] softenedQuestionIds: Set }): Map => { const questionById = new Map (questions.map (question => [question.id, question])) const nextScores = new Map () answers.forEach (answer => { const question = questionById.get (answer.questionId) if (!(question)) return const weight = answerWeightFor (answer.questionId, softenedQuestionIds) posts.forEach (post => { nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer.answer) * weight) }) }) return nextScores } const confidencesFor = (posts: Post[], scores: Map): Confidence[] => { if (posts.length === 0) return [] const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 })) const maxScore = Math.max (...raw.map (({ score }) => score)) const weighted = raw.map (item => ({ ...item, weight: Math.exp ((item.score - maxScore) / confidenceTemperature) })) const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1 return weighted .map (({ post, score, weight }) => ({ post, score, percent: weight / total * 100 })) .sort ((a, b) => b.percent - a.percent) } const entropyFor = (confidences: Confidence[]): number => confidences.reduce ((sum, item) => { const p = item.percent / 100 return p > 0 ? sum - p * Math.log2 (p) : sum }, 0) const effectiveCandidatesFor = (confidences: Confidence[]): number => { const concentration = confidences.reduce ((sum, item) => { const p = item.percent / 100 return sum + p * p }, 0) return concentration > 0 ? 1 / concentration : 0 } const previewAnswer = ({ posts, scores, question, answer, }: { posts: Post[] scores: Map question: GekanatorQuestion answer: GekanatorAnswerValue }): AnswerPreview => { const hardFilteredPosts = answer === 'yes' ? posts.filter (post => question.test (post)) : answer === 'no' ? posts.filter (post => !(question.test (post))) : posts const nextPosts = (answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0 ? hardFilteredPosts : posts const nextScores = new Map (scores) nextPosts.forEach (post => { nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer)) }) const confidences = confidencesFor (nextPosts, nextScores) return { answer, top: confidences[0] ?? null, candidateCount: nextPosts.length, effectiveCandidates: effectiveCandidatesFor (confidences), entropy: entropyFor (confidences) } } const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => { const byId = new Map () questions.forEach (question => byId.set (question.id, question)) return [...byId.values ()] } const softenNextQuestionIds = ({ questions, answers, softenedQuestionIds, }: { questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] softenedQuestionIds: Set }): Set | null => { const questionById = new Map (questions.map (question => [question.id, question])) const candidate = [...answers] .reverse () .map (answer => { const question = questionById.get (answer.questionId) return { answer, question } }) .filter ((item): item is { answer: GekanatorAnswerLog question: GekanatorQuestion } => item.question !== undefined && item.answer.answer !== 'unknown' && !(softenedQuestionIds.has (item.answer.questionId))) .sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0] if (!(candidate)) return null return new Set ([...softenedQuestionIds, candidate.answer.questionId]) } const chooseQuestion = ({ posts, questions, scores, askedIds, }: { posts: Post[] questions: GekanatorQuestion[] scores: Map askedIds: Set }): GekanatorQuestion | null => { const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) const signatureFor = ( question: GekanatorQuestion, candidates: { post: Post; score: number }[], ): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('') const invertedSignature = (signature: string): string => signature.replace (/[01]/g, value => value === '1' ? '0' : '1') const redundantSignatures = ( candidates: { post: Post; score: number }[], ): Set => { const signatures = new Set () questions .filter (question => askedIds.has (question.id)) .forEach (question => { const signature = signatureFor (question, candidates) signatures.add (signature) signatures.add (invertedSignature (signature)) }) return signatures } const rank = ( questionsToRank: GekanatorQuestion[], candidates: { post: Post; score: number }[], ) => { const redundant = redundantSignatures (candidates) return questionsToRank .map (question => { const signature = signatureFor (question, candidates) if (redundant.has (signature)) return null const yes = signature.split ('').filter (value => value === '1').length const no = candidates.length - yes if (yes === 0 || no === 0) return null const splitScore = Math.abs (candidates.length / 2 - yes) const answerPreviews = answerOptions.map (option => previewAnswer ({ posts: candidates.map (({ post }) => post), scores, question, answer: option.value })) const expectedEntropy = answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0) / answerPreviews.length const expectedCandidateCount = answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0) / answerPreviews.length const kindPenalty = askedIds.has (question.kind) ? 2 : 0 const tagPenalty = question.kind === 'tag' ? 0 : 10 const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0 return { question, score: splitScore + expectedEntropy + expectedCandidateCount / 8 + kindPenalty + tagPenalty + narrowPenalty, narrow: narrowPenalty > 0 } }) .filter ((item): item is { question: GekanatorQuestion score: number narrow: boolean } => item !== null && Number.isFinite (item.score)) .sort ((a, b) => a.score - b.score) } const unansweredQuestions = questions.filter (question => !(askedIds.has (question.id))) const ranked = rank (unansweredQuestions, scoredPosts) return (ranked.find (item => !(item.narrow)) ?? ranked[0])?.question ?? null } const bestPost = (posts: Post[], scores: Map): Post | null => posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score)[0]?.post ?? null const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
{post.title
#{post.id} {post.title || post.url}
{post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
) const GekanatorPage: FC = () => { const [phase, setPhase] = useState ('intro') const [scores, setScores] = useState> (new Map ()) const [answers, setAnswers] = useState ([]) const [askedIds, setAskedIds] = useState> (new Set ()) const [candidateIds, setCandidateIds] = useState | null> (null) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> (new Set ()) const [questionBank, setQuestionBank] = useState ([]) const [search, setSearch] = useState ('') const [saved, setSaved] = useState (false) const [resultWon, setResultWon] = useState (null) const [rejectedPostIds, setRejectedPostIds] = useState> (new Set ()) const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0) const [lastRejectedGuessId, setLastRejectedGuessId] = useState (null) const [activeGuessId, setActiveGuessId] = useState (null) const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ queryKey: gekanatorKeys.posts (), queryFn: fetchGekanatorPosts }) const candidatePosts = useMemo ( () => posts.filter (post => candidateIds === null || candidateIds.has (post.id)), [posts, candidateIds]) const eligiblePosts = useMemo ( () => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))), [candidatePosts, rejectedPostIds]) const questions = useMemo ( () => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), [eligiblePosts, posts]) const scoringQuestions = useMemo (() => { return mergeQuestions ([...questions, ...questionBank]) }, [questions, questionBank]) const topScoredPosts = useMemo ( () => eligiblePosts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) .slice (0, 3), [eligiblePosts, scores]) const currentQuestion = chooseQuestion ({ posts: eligiblePosts, questions: scoringQuestions, scores, askedIds }) const answerPreviews = useMemo ( () => currentQuestion ? answerOptions.map (option => previewAnswer ({ posts: eligiblePosts, scores, question: currentQuestion, answer: option.value })) : [], [currentQuestion, eligiblePosts, scores]) const guess = bestPost (eligiblePosts, scores) const displayedGuess = posts.find (post => post.id === activeGuessId) ?? guess const saveMutation = useMutation ({ mutationFn: saveGekanatorGame }) const reset = () => { setPhase ('intro') setScores (new Map ()) setAnswers ([]) setAskedIds (new Set ()) setCandidateIds (null) setSoftenedQuestionIds (new Set ()) setQuestionBank ([]) setSearch ('') setSaved (false) setResultWon (null) setRejectedPostIds (new Set ()) setLastGuessQuestionCount (0) setLastRejectedGuessId (null) setActiveGuessId (null) setHistory ([]) } const answer = (value: GekanatorAnswerValue) => { if (!(currentQuestion)) { setActiveGuessId (guess?.id ?? null) setPhase ('guess') return } setHistory ([...history, { phase, scores: new Map (scores), answers: [...answers], askedIds: new Set (askedIds), candidateIds: candidateIds === null ? null : new Set (candidateIds), softenedQuestionIds: new Set (softenedQuestionIds), questionBank: [...questionBank], search, rejectedPostIds: new Set (rejectedPostIds), lastGuessQuestionCount, lastRejectedGuessId, activeGuessId }]) const nextAnswers = [...answers, { questionId: currentQuestion.id, questionText: currentQuestion.text, answer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextQuestionBank = [ ...questionBank.filter (question => question.id !== currentQuestion.id), currentQuestion] const hardFilteredPosts = value === 'yes' ? eligiblePosts.filter (post => currentQuestion.test (post)) : value === 'no' ? eligiblePosts.filter (post => !(currentQuestion.test (post))) : eligiblePosts let nextCandidateIds = (value === 'yes' || value === 'no') && hardFilteredPosts.length > 0 ? new Set (hardFilteredPosts.map (post => post.id)) : candidateIds let nextSoftenedQuestionIds = new Set (softenedQuestionIds) let nextScores = recalculateScores ({ posts, questions: nextQuestionBank, answers: nextAnswers, softenedQuestionIds: nextSoftenedQuestionIds }) let nextEligiblePosts = posts.filter (post => (nextCandidateIds === null || nextCandidateIds.has (post.id)) && !(rejectedPostIds.has (post.id))) let nextScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts), ...nextQuestionBank]) while ( nextAnswers.length < hardMaxQuestions && nextEligiblePosts.length > 1 && !(chooseQuestion ({ posts: nextEligiblePosts, questions: nextScoringQuestions, scores: nextScores, askedIds: nextAskedIds })) ) { const softened = softenNextQuestionIds ({ questions: nextQuestionBank, answers: nextAnswers, softenedQuestionIds: nextSoftenedQuestionIds }) if (!(softened)) break nextSoftenedQuestionIds = softened nextCandidateIds = null nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id))) nextScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions (nextEligiblePosts), ...nextQuestionBank]) nextScores = recalculateScores ({ posts, questions: nextQuestionBank, answers: nextAnswers, softenedQuestionIds: nextSoftenedQuestionIds }) } setScores (nextScores) setAskedIds (nextAskedIds) setCandidateIds (nextCandidateIds) setSoftenedQuestionIds (nextSoftenedQuestionIds) setQuestionBank (nextQuestionBank) setAnswers (nextAnswers) const nextGuess = bestPost (nextEligiblePosts, nextScores) const nextQuestionCount = answers.length + 1 const definitelyKnown = nextEligiblePosts.length === 1 const enoughQuestions = nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses const shouldGuess = nextQuestionCount >= hardMaxQuestions || definitelyKnown || enoughQuestions if (shouldGuess) { setActiveGuessId (nextGuess?.id ?? null) setLastGuessQuestionCount (nextQuestionCount) setPhase ('guess') } } const saveResult = (won: boolean, correctPostId: number | null) => { const guessedPostId = won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id if (!(guessedPostId) || saved) return setSaved (true) setResultWon (won) saveMutation.mutate ({ guessedPostId, correctPostId, won, answers }) setPhase ('learned') } const rejectGuess = () => { if (!(displayedGuess)) return setLastRejectedGuessId (displayedGuess.id) if (answers.length >= hardMaxQuestions) { setSearch (' ') return } setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) setActiveGuessId (null) setSearch ('') setLastGuessQuestionCount (answers.length) setPhase ('continue') } const undoAnswer = () => { const snapshot = history[history.length - 1] if (!(snapshot) || saved) return setPhase (snapshot.phase) setScores (snapshot.scores) setAnswers (snapshot.answers) setAskedIds (snapshot.askedIds) setCandidateIds (snapshot.candidateIds) setSoftenedQuestionIds (snapshot.softenedQuestionIds) setQuestionBank (snapshot.questionBank) setSearch (snapshot.search) setRejectedPostIds (snapshot.rejectedPostIds) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastRejectedGuessId (snapshot.lastRejectedGuessId) setActiveGuessId (snapshot.activeGuessId) setHistory (history.slice (0, -1)) } const softenAndContinue = () => { const softened = softenNextQuestionIds ({ questions: scoringQuestions, answers, softenedQuestionIds }) if (!(softened)) return setSoftenedQuestionIds (softened) setCandidateIds (null) setScores ( recalculateScores ({ posts, questions: scoringQuestions, answers, softenedQuestionIds: softened })) } const filteredPosts = posts .filter (post => { const needle = search.trim ().toLowerCase () if (!(needle)) return false if (/^\d+$/.test (needle) && post.id === Number (needle)) return true return [post.title, post.url, ...post.tags.map (tag => tag.name)] .filter ((value): value is string => Boolean (value)) .some (value => value.toLowerCase ().includes (needle)) }) .sort ((a, b) => { const id = Number (search.trim ()) if (Number.isFinite (id)) return Number (b.id === id) - Number (a.id === id) return 0 }) .slice (0, 20) const dialogue = phase === 'learned' && resultWon ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ return ( {`グカネータ | ${ SITE_TITLE }`}

おたのしみ

グカネータ

洗澡鹿

{dialogue}

{isLoading &&

投稿を読み込んでゐます...

} {Boolean (error) &&

投稿を読み込めませんでした.

} {phase === 'intro' && !(isLoading) && posts.length > 0 && ( )} {phase === 'question' && currentQuestion && (

質問 {answers.length + 1}

{currentQuestion.text}

現在候補: {eligiblePosts.length} 件
{topScoredPosts.length > 0 && (
{topScoredPosts.map (item => ( #{item.post.id}: score {item.score.toFixed (1)} ))}
)}
{answerPreviews.length > 0 && (
{answerOptions.map (option => { const preview = answerPreviews.find (item => item.answer === option.value) return (
{option.label} {' '} なら候補 {preview ? preview.candidateCount : 0} 件
) })}
)}
{answerOptions.map (option => ( ))} {history.length > 0 && ( )}
)} {phase === 'question' && !(currentQuestion) && (

さっきまでの答へを少し疑って考へ直すよ.

{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1 ? ( ) : ( )}
)} {phase === 'guess' && displayedGuess && (

これを想像してゐたね?

{history.length > 0 && ( )}
)} {phase === 'continue' && (

続けますか?

{history.length > 0 && ( )}
)} {phase === 'learned' && (

覚えたよ.次はもっと見通す.

{saveMutation.isError && (

ただし学習ログの保存には失敗しました.

)}
)}
{['guess', 'continue', 'question'].includes (phase) && search !== '' && (
{filteredPosts.map (post => ( ))} {search.trim () && filteredPosts.length === 0 && '見つかりません.'}
)}
) } export default GekanatorPage