import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, 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, expectedAnswerForQuestion, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, normalizeTitleLengthCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, storeGekanatorQuestion, titleLengthMinimumForCondition } from '@/lib/gekanator' import { gekanatorKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import type { FC } from 'react' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorExtraQuestion, GekanatorQuestionCondition, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' import type { Post } from '@/types' type Phase = | 'intro' | 'question' | 'guess' | 'continue' | 'end' | 'review' | 'question_suggestion' | 'extra_questions' | '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 softenedQuestionIds: Set askedQuestionBank: GekanatorQuestion[] search: string selectingCorrectPost: boolean rejectedPostIds: Set lastGuessQuestionCount: number lastRejectedGuessId: number | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null } type StoredGekanatorGame = { phase: Phase scores: [number, number][] answers: GekanatorAnswerLog[] askedIds: string[] softenedQuestionIds: string[] askedQuestionBank?: StoredGekanatorQuestion[] askedQuestionBankIds?: string[] search: string selectingCorrectPost: boolean saved: boolean resultWon: boolean | null rejectedPostIds: number[] lastGuessQuestionCount: number lastRejectedGuessId: number | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null savedGameId: number | null gameSeed?: string questionSuggestion: string questionSuggestionAnswer: GekanatorAnswerValue questionSuggestionCount?: number extraQuestions?: GekanatorExtraQuestion[] extraQuestionAnswers?: Record extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' } const answerOptions: AnswerOption[] = [ { label: 'はい', value: 'yes' }, { label: 'いいえ', value: 'no' }, { label: '部分的にそう', value: 'partial' }, { label: 'たぶんいいえ', value: 'probably_no' }, { label: 'わからない', value: 'unknown' }] const answerLabelFor = (value: GekanatorAnswerValue): string => answerOptions.find (option => option.value === value)?.label ?? value const questionsBetweenGuesses = 25 const minQuestionsBeforeCertainGuess = 5 const certainGuessPercent = 99.5 const runnerUpMaxPercent = .5 const hardMaxQuestions = 80 const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' const maxQuestionSuggestionsPerGame = 3 const sourcePriorityOffset = (question: GekanatorQuestion): number => { switch (question.source) { case 'user_suggested': return -1.2 case 'admin_curated': return -0.8 case 'ai_generated': return -0.6 default: return 0 } } const priorityWeightOffset = (question: GekanatorQuestion): number => (Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8 const createGameSeed = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID () return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }` } const normalizeStoredQuestionId = ( questionId: string, condition?: GekanatorQuestionCondition, ): string => { if (condition?.type === 'title-length-greater-than') return `title:length-at-least:${ condition.length + 1 }` if (questionId.startsWith ('title:length-greater-than:')) { const length = Number (questionId.split (':').pop ()) if (Number.isInteger (length)) return `title:length-at-least:${ length + 1 }` } return questionId } const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({ ...game, answers: game.answers.map (answer => ({ ...answer, questionId: normalizeStoredQuestionId ( answer.questionId, answer.questionCondition), questionCondition: answer.questionCondition ? normalizeTitleLengthCondition (answer.questionCondition) : undefined })), askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), softenedQuestionIds: game.softenedQuestionIds.map (questionId => normalizeStoredQuestionId (questionId)), askedQuestionBank: game.askedQuestionBank?.map (question => ({ ...question, id: normalizeStoredQuestionId (question.id, question.condition), condition: normalizeTitleLengthCondition (question.condition) })), askedQuestionBankIds: game.askedQuestionBankIds?.map (questionId => normalizeStoredQuestionId (questionId)) }) const sourcePriorityForMerge = (question: GekanatorQuestion): number => { switch (question.source) { case 'user_suggested': return 3 case 'admin_curated': return 3 case 'ai_generated': return 3 default: return 1 } } const shouldReplaceMergedQuestion = ( current: GekanatorQuestion | undefined, candidate: GekanatorQuestion, ): boolean => { if (!(current)) return true const currentSourcePriority = sourcePriorityForMerge (current) const candidateSourcePriority = sourcePriorityForMerge (candidate) if (candidateSourcePriority !== currentSourcePriority) return candidateSourcePriority > currentSourcePriority if (candidate.priorityWeight !== current.priorityWeight) return candidate.priorityWeight > current.priorityWeight return true } const hashString = (value: string): number => { let hash = 2166136261 for (let i = 0; i < value.length; i += 1) { hash ^= value.charCodeAt (i) hash = Math.imul (hash, 16777619) } return hash >>> 0 } const deterministicUnitFloat = (seed: string): number => hashString (seed) / 4294967295 const clearStoredGame = (): void => { try { sessionStorage.removeItem (gameStorageKey) } catch { return } } const loadStoredGame = (): StoredGekanatorGame | null => { try { const raw = sessionStorage.getItem (gameStorageKey) if (!(raw)) return null return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame) } catch { clearStoredGame () return null } } const isStoredPhase = (phase: Phase): boolean => phase !== 'intro' const resettableExtraQuestionState = (): { extraQuestions: GekanatorExtraQuestion[] extraQuestionAnswers: Record extraQuestionState: 'idle' } => ({ extraQuestions: [], extraQuestionAnswers: { }, extraQuestionState: 'idle' }) 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 answerScalarFor = ( answer: GekanatorAnswerValue | null, ): number | null => { switch (answer) { case 'yes': return 1 case 'partial': return .5 case 'probably_no': return -.5 case 'no': return -1 case 'unknown': case null: return null } } const deltaForExpectedAnswer = ( expected: GekanatorAnswerValue | null, answer: GekanatorAnswerValue, ): number => { if (answer === 'unknown' || expected === null || expected === 'unknown') return 0 if (expected === 'yes' || expected === 'no') return deltaFor (expected === 'yes', answer) const expectedScalar = answerScalarFor (expected) const answerScalar = answerScalarFor (answer) if (expectedScalar === null || answerScalar === null) return 0 const distance = Math.abs (expectedScalar - answerScalar) if (distance >= 2) return -4 if (distance >= 1.5) return -2 if (distance >= 1) return 0 if (distance >= .5) return 2 return 4 } const answerWeightFor = ( questionId: string, softenedQuestionIds: Set, ): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 const questionDifficulty = (question: GekanatorQuestion): number => { if (question.kind === 'source') return 4 if (question.kind === 'original_date') return 4 if (question.kind === 'title') return 4 if (question.kind === 'tag') return 3 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 => { const expected = expectedAnswerForQuestion (question, post) nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer.answer) * weight) }) }) return nextScores } const candidatePostsFor = ({ posts, questions, answers, softenedQuestionIds, rejectedPostIds, }: { posts: Post[] questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] softenedQuestionIds: Set rejectedPostIds: Set }): Post[] => { const questionById = new Map (questions.map (question => [question.id, question])) return posts.filter (post => { if (rejectedPostIds.has (post.id)) return false return answers.every (answer => { if (softenedQuestionIds.has (answer.questionId)) return true const question = questionById.get (answer.questionId) if (!(question)) return true switch (answer.answer) { case 'yes': case 'no': { const expected = expectedAnswerForQuestion (question, post) return expected === null || expected === 'unknown' || expected === answer.answer } default: return true } }) }) } 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 === 'unknown' ? posts : posts.filter (post => { const expected = expectedAnswerForQuestion (question, post) return expected === null || expected === 'unknown' || expected === answer }) const nextPosts = hardFilteredPosts.length > 0 ? hardFilteredPosts : posts const nextScores = new Map (scores) nextPosts.forEach (post => { const expected = expectedAnswerForQuestion (question, post) nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, 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 => { const current = byId.get (question.id) if (shouldReplaceMergedQuestion (current, 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]) } type ExclusiveConditionGroup = | 'original-month' | 'original-year' | 'original-month-day' | 'source' const exclusiveConditionGroupFor = ( condition: GekanatorQuestion['condition'], ): ExclusiveConditionGroup | null => { switch (condition.type) { case 'original-month': return 'original-month' case 'original-year': return 'original-year' case 'original-month-day': return 'original-month-day' case 'source': return 'source' default: return null } } const sameConditionValue = ( left: GekanatorQuestion['condition'], right: GekanatorQuestion['condition'], ): boolean => { const leftTitleLength = titleLengthMinimumForCondition (left) const rightTitleLength = titleLengthMinimumForCondition (right) if (leftTitleLength !== null || rightTitleLength !== null) return leftTitleLength !== null && rightTitleLength !== null && leftTitleLength === rightTitleLength if (left.type !== right.type) return false const valueKeyFor = (condition: GekanatorQuestion['condition']): string => { switch (condition.type) { case 'tag': return condition.key case 'source': return condition.host case 'original-year': return String (condition.year) case 'original-month': return String (condition.month) case 'original-month-day': return condition.monthDay case 'title-has-ascii': return '' case 'post-similarity': return `${ condition.postId }:${ condition.answer }:${ condition.threshold }` case 'title-length-at-least': case 'title-length-greater-than': return String (titleLengthMinimumForCondition (condition) ?? '') } } return valueKeyFor (left) === valueKeyFor (right) } const monthForCondition = ( condition: GekanatorQuestion['condition'], ): number | null => { if (condition.type === 'original-month') return condition.month if (condition.type !== 'original-month-day') return null const month = Number (condition.monthDay.split ('-')[0]) return Number.isInteger (month) ? month : null } const isTitleLengthContradiction = ( candidate: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'], answer: GekanatorAnswerValue, ): boolean => { const candidateLength = titleLengthMinimumForCondition (candidate) const previousLength = titleLengthMinimumForCondition (previous) if (candidateLength === null || previousLength === null) return false switch (answer) { case 'yes': return candidateLength <= previousLength case 'no': return candidateLength >= previousLength default: return false } } const isQuestionRedundantAfterAnswers = ( question: GekanatorQuestion, answers: GekanatorAnswerLog[], ): boolean => answers.some (answer => { const previous = answer.questionCondition return previous !== undefined && isTitleLengthContradiction (question.condition, previous, answer.answer) }) const isMonthCrossMatch = ( candidate: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'], ): boolean => { const candidateMonth = monthForCondition (candidate) const previousMonth = monthForCondition (previous) if (candidateMonth === null || previousMonth === null) return false const sameType = candidate.type === previous.type if (sameType) return false return candidateMonth === previousMonth } const isExclusiveContradiction = ( candidate: GekanatorQuestion['condition'], previous: GekanatorQuestion['condition'], ): boolean => { const candidateGroup = exclusiveConditionGroupFor (candidate) const previousGroup = exclusiveConditionGroupFor (previous) if (candidateGroup !== null && candidateGroup === previousGroup) return !(sameConditionValue (candidate, previous)) const candidateMonth = monthForCondition (candidate) const previousMonth = monthForCondition (previous) if (candidateMonth !== null && previousMonth !== null) return candidateMonth !== previousMonth return false } const contradictionPenaltyFor = ({ question, answers, }: { question: GekanatorQuestion answers: GekanatorAnswerLog[] }): number => { return answers.reduce ((sum, answer) => { const previous = answer.questionCondition if (!(previous)) return sum switch (answer.answer) { case 'yes': return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0) case 'partial': return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0) case 'no': return sum + ( sameConditionValue (question.condition, previous) || isMonthCrossMatch (question.condition, previous) ? 40 : 0) case 'probably_no': return sum + ( sameConditionValue (question.condition, previous) || isMonthCrossMatch (question.condition, previous) ? 20 : 0) default: return sum } }, 0) } const chooseQuestion = ({ posts, questions, scores, answers, askedIds, gameSeed, }: { posts: Post[] questions: GekanatorQuestion[] scores: Map answers: GekanatorAnswerLog[] askedIds: Set gameSeed: string }): GekanatorQuestion | null => { const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) const maxScore = scoredPosts[0]?.score ?? 0 const weightedPosts = scoredPosts.map (item => ({ ...item, weight: Math.exp ((item.score - maxScore) / confidenceTemperature) })) const totalWeight = weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1 const normalisedWeightedPosts = weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight })) 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 }[], weightedCandidates: { post: Post; score: number; weight: number }[], ) => { const redundant = redundantSignatures (candidates) const nonTagCount = questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length return questionsToRank .map (question => { if (isQuestionRedundantAfterAnswers (question, answers)) return null 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 yesWeight = weightedCandidates.reduce ( (sum, item) => sum + (question.test (item.post) ? item.weight : 0), 0) const noWeight = 1 - yesWeight if (yesWeight <= 0 || noWeight <= 0) return null const weightedSplitScore = Math.abs (.5 - yesWeight) const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0 const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08) const narrowPenalty = yes < minSide || no < minSide ? .15 : 0 const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) const sourceBonus = sourcePriorityOffset (question) const priorityBonus = priorityWeightOffset (question) return { question, score: weightedSplitScore * 100 + unweightedSplitScore * 8 + tagPenalty + narrowPenalty + contradictionPenalty + sourceBonus + priorityBonus, 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, normalisedWeightedPosts) const pool = ( ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked) .slice (0, 12) if (pool.length === 0) return null const bestScore = pool[0]?.score ?? 0 const weightedPool = pool.map (item => ({ ...item, weight: Math.exp ((bestScore - item.score) / 1.8) })) const totalPoolWeight = weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1 const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${ weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|') }` const target = deterministicUnitFloat (seed) * totalPoolWeight let cumulative = 0 for (const item of weightedPool) { cumulative += item.weight if (target <= cumulative) return item.question } return weightedPool[weightedPool.length - 1]?.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 expectedAnswerFor = ( question: GekanatorQuestion | undefined, correctPost: Post | null, ): GekanatorAnswerValue | null => expectedAnswerForQuestion (question, correctPost) const GekanatorPage: FC = () => { const storedGame = useMemo (loadStoredGame, []) const queryClient = useQueryClient () const [gameSeed, setGameSeed] = useState ( storedGame?.gameSeed ?? createGameSeed ()) const [phase, setPhase] = useState (storedGame?.phase ?? 'intro') const [scores, setScores] = useState> ( () => new Map (storedGame?.scores ?? [])) const [answers, setAnswers] = useState ( storedGame?.answers ?? []) const [askedIds, setAskedIds] = useState> ( () => new Set (storedGame?.askedIds ?? [])) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> ( () => new Set (storedGame?.softenedQuestionIds ?? [])) const [askedQuestionBank, setAskedQuestionBank] = useState ( () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( (storedGame?.askedQuestionBank?.length ?? 0) > 0 ? [] : storedGame?.askedQuestionBankIds ?? []) const [search, setSearch] = useState (storedGame?.search ?? '') const [selectingCorrectPost, setSelectingCorrectPost] = useState ( storedGame?.selectingCorrectPost ?? false) const [saved, setSaved] = useState (storedGame?.saved ?? false) const [resultWon, setResultWon] = useState ( storedGame?.resultWon ?? null) const [rejectedPostIds, setRejectedPostIds] = useState> ( () => new Set (storedGame?.rejectedPostIds ?? [])) const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState ( storedGame?.lastGuessQuestionCount ?? 0) const [lastRejectedGuessId, setLastRejectedGuessId] = useState ( storedGame?.lastRejectedGuessId ?? null) const [activeGuessId, setActiveGuessId] = useState ( storedGame?.activeGuessId ?? null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState ( storedGame?.reviewGuessedPostId ?? null) const [reviewCorrectPostId, setReviewCorrectPostId] = useState ( storedGame?.reviewCorrectPostId ?? null) const [savedGameId, setSavedGameId] = useState ( storedGame?.savedGameId ?? null) const [questionSuggestion, setQuestionSuggestion] = useState ( storedGame?.questionSuggestion ?? '') const [questionSuggestionAnswer, setQuestionSuggestionAnswer] = useState (storedGame?.questionSuggestionAnswer ?? 'yes') const [questionSuggestionCount, setQuestionSuggestionCount] = useState ( storedGame?.questionSuggestionCount ?? 0) const [extraQuestions, setExtraQuestions] = useState ( storedGame?.extraQuestions ?? []) const [extraQuestionAnswers, setExtraQuestionAnswers] = useState> ( storedGame?.extraQuestionAnswers ?? { }) const [extraQuestionState, setExtraQuestionState] = useState< 'idle' | 'loading' | 'ready' | 'empty' | 'saved' > (storedGame?.extraQuestionState ?? 'idle') const [history, setHistory] = useState ([]) const { data: posts = [], isLoading, error } = useQuery ({ queryKey: gekanatorKeys.posts (), queryFn: fetchGekanatorPosts, refetchOnWindowFocus: false }) const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched, isLoading: acceptedQuestionsLoading, error: acceptedQuestionsError } = useQuery ({ queryKey: gekanatorKeys.questions (), queryFn: fetchGekanatorQuestions, select: questions => questions.map (restoreGekanatorQuestion), refetchOnWindowFocus: false }) useEffect (() => { if ( posts.length === 0 || storedAskedQuestionBankIds.length === 0 || !(acceptedQuestionsFetched) ) return const questionById = new Map ( mergeQuestions ([ ...buildGekanatorQuestions (posts), ...acceptedQuestions]) .map (question => [question.id, question])) setAskedQuestionBank ( storedAskedQuestionBankIds .map (questionId => questionById.get (questionId)) .filter ((question): question is GekanatorQuestion => question !== undefined)) setStoredAskedQuestionBankIds ([]) }, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched]) useEffect (() => { if (!(isStoredPhase (phase)) && answers.length === 0) { clearStoredGame () return } const stored: StoredGekanatorGame = { phase, scores: [...scores.entries ()], answers, askedIds: [...askedIds], softenedQuestionIds: [...softenedQuestionIds], askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion), askedQuestionBankIds: storedAskedQuestionBankIds, search, selectingCorrectPost, saved, resultWon, rejectedPostIds: [...rejectedPostIds], lastGuessQuestionCount, lastRejectedGuessId, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, savedGameId, gameSeed, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, extraQuestions, extraQuestionAnswers, extraQuestionState } try { sessionStorage.setItem (gameStorageKey, JSON.stringify (stored)) } catch { return } }, [ phase, scores, answers, askedIds, softenedQuestionIds, askedQuestionBank, storedAskedQuestionBankIds, search, selectingCorrectPost, saved, resultWon, rejectedPostIds, lastGuessQuestionCount, lastRejectedGuessId, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, savedGameId, gameSeed, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, extraQuestions, extraQuestionAnswers, extraQuestionState]) const eligiblePosts = useMemo ( () => candidatePostsFor ({ posts, questions: askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds }), [posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds]) const questions = useMemo ( () => mergeQuestions ([ ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), ...acceptedQuestions]), [acceptedQuestions, eligiblePosts, posts]) const scoringQuestions = useMemo (() => { return mergeQuestions ([...questions, ...askedQuestionBank]) }, [questions, askedQuestionBank]) const scoringQuestionById = useMemo ( () => new Map (scoringQuestions.map (question => [question.id, question])), [scoringQuestions]) const questionsSinceLastGuess = answers.length - lastGuessQuestionCount const nonRejectedPosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) const questionPosts = eligiblePosts.length > 1 || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess ? eligiblePosts : nonRejectedPosts 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: questionPosts, questions: scoringQuestions, scores, answers, askedIds, gameSeed }) const answerPreviews = useMemo ( () => currentQuestion ? answerOptions.map (option => previewAnswer ({ posts: eligiblePosts, scores, question: currentQuestion, answer: option.value })) : [], [currentQuestion, eligiblePosts, scores]) const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : nonRejectedPosts const guess = bestPost (guessablePosts, scores) const displayedGuess = posts.find (post => post.id === activeGuessId) ?? guess const reviewGuessedPost = posts.find (post => post.id === reviewGuessedPostId) ?? null const reviewCorrectPost = posts.find (post => post.id === reviewCorrectPostId) ?? null const saveMutation = useMutation ({ mutationFn: saveGekanatorGame, onSuccess: (data, variables) => { setSaved (true) setSavedGameId (data.id) setResultWon (variables.guessedPostId === variables.correctPostId) }}) const questionSuggestionMutation = useMutation ({ mutationFn: saveGekanatorQuestionSuggestion, onSuccess: async data => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setQuestionSuggestionCount (data.count) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') }}) const extraQuestionAnswersMutation = useMutation ({ mutationFn: saveGekanatorExtraQuestionAnswers, onSuccess: async () => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setExtraQuestionState ('saved') setPhase ('learned') }}) const resetExtraQuestionState = () => { const next = resettableExtraQuestionState () setExtraQuestions (next.extraQuestions) setExtraQuestionAnswers (next.extraQuestionAnswers) setExtraQuestionState (next.extraQuestionState) extraQuestionAnswersMutation.reset () } const reset = () => { clearStoredGame () saveMutation.reset () questionSuggestionMutation.reset () setPhase ('intro') setScores (new Map ()) setAnswers ([]) setAskedIds (new Set ()) setSoftenedQuestionIds (new Set ()) setAskedQuestionBank ([]) setSearch ('') setSelectingCorrectPost (false) setSaved (false) setResultWon (null) setRejectedPostIds (new Set ()) setLastGuessQuestionCount (0) setLastRejectedGuessId (null) setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) setSavedGameId (null) setGameSeed (createGameSeed ()) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') setQuestionSuggestionCount (0) resetExtraQuestionState () setHistory ([]) } const recoverQuestionState = ({ nextAnswers, nextAskedIds, nextAskedQuestionBank, nextSoftenedQuestionIds, nextRejectedPostIds, }: { nextAnswers: GekanatorAnswerLog[] nextAskedIds: Set nextAskedQuestionBank: GekanatorQuestion[] nextSoftenedQuestionIds: Set nextRejectedPostIds: Set }) => { let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds) let recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) let recoveredEligiblePosts = candidatePostsFor ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds }) let recoveredScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions ( recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), ...acceptedQuestions, ...nextAskedQuestionBank]) while ( recoveredEligiblePosts.length === 0 || ( recoveredEligiblePosts.length !== 1 && !(chooseQuestion ({ posts: recoveredEligiblePosts, questions: recoveredScoringQuestions, scores: recoveredScores, answers: nextAnswers, askedIds: nextAskedIds, gameSeed }))) ) { if (nextAnswers.length >= hardMaxQuestions) break const softened = softenNextQuestionIds ({ questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) if (!(softened)) break recoveredSoftenedQuestionIds = softened recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) recoveredEligiblePosts = candidatePostsFor ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds }) recoveredScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions ( recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), ...acceptedQuestions, ...nextAskedQuestionBank]) } return { softenedQuestionIds: recoveredSoftenedQuestionIds, scores: recoveredScores, eligiblePosts: recoveredEligiblePosts, scoringQuestions: recoveredScoringQuestions } } 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), softenedQuestionIds: new Set (softenedQuestionIds), askedQuestionBank: [...askedQuestionBank], search, selectingCorrectPost, rejectedPostIds: new Set (rejectedPostIds), lastGuessQuestionCount, lastRejectedGuessId, activeGuessId, reviewGuessedPostId, reviewCorrectPostId }]) const nextAnswers = [...answers, { questionId: currentQuestion.id, questionText: currentQuestion.text, questionCondition: currentQuestion.condition, answer: value, originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) const nextAskedQuestionBank = [ ...askedQuestionBank.filter (question => question.id !== currentQuestion.id), currentQuestion] const recovered = recoverQuestionState ({ nextAnswers, nextAskedIds, nextAskedQuestionBank, nextSoftenedQuestionIds: softenedQuestionIds, nextRejectedPostIds: rejectedPostIds }) const nextSoftenedQuestionIds = recovered.softenedQuestionIds const nextScores = recovered.scores const nextEligiblePosts = recovered.eligiblePosts setScores (nextScores) setAskedIds (nextAskedIds) setSoftenedQuestionIds (nextSoftenedQuestionIds) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) const nextGuessablePosts = nextEligiblePosts.length > 0 ? nextEligiblePosts : nonRejectedPosts const nextGuess = bestPost (nextGuessablePosts, nextScores) const nextQuestionCount = answers.length + 1 const nextQuestionsSinceLastGuess = nextQuestionCount - lastGuessQuestionCount const nextConfidences = confidencesFor (nextGuessablePosts, nextScores) const topConfidence = nextConfidences[0] ?? null const runnerUpConfidence = nextConfidences[1] ?? null const structurallyCertain = nextEligiblePosts.length === 1 const statisticallyCertain = topConfidence !== null && topConfidence.percent >= certainGuessPercent && (runnerUpConfidence === null || runnerUpConfidence.percent <= runnerUpMaxPercent) const canGuessByQuestionCount = nextQuestionsSinceLastGuess >= questionsBetweenGuesses const canGuessEarlyByConfidence = nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess && (structurallyCertain || statisticallyCertain) const shouldGuess = nextQuestionCount >= hardMaxQuestions || canGuessByQuestionCount || canGuessEarlyByConfidence if (shouldGuess) { setActiveGuessId (nextGuess?.id ?? null) setLastGuessQuestionCount (nextQuestionCount) setPhase ('guess') } } const finishGame = (correctPostId: number) => { const guessedPostId = phase === 'end' || phase === 'review' ? reviewGuessedPostId : phase === 'continue' ? lastRejectedGuessId ?? displayedGuess?.id : displayedGuess?.id ?? lastRejectedGuessId if (!(guessedPostId)) return saveMutation.reset () questionSuggestionMutation.reset () resetExtraQuestionState () setSaved (false) setSavedGameId (null) setReviewGuessedPostId (guessedPostId) setReviewCorrectPostId (correctPostId) setSearch ('') setSelectingCorrectPost (false) setPhase ('end') } const startReview = () => { if (reviewGuessedPostId === null || reviewCorrectPostId === null) return saveMutation.reset () questionSuggestionMutation.reset () resetExtraQuestionState () setSaved (false) setSavedGameId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') } const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( reviewGuessedPostId === null || reviewCorrectPostId === null || saveMutation.isPending ) return if (savedGameId !== null) { onSuccess (savedGameId) return } saveMutation.mutate ({ guessedPostId: reviewGuessedPostId, correctPostId: reviewCorrectPostId, answers }, { onSuccess: data => onSuccess (data.id) }) } const saveAndReset = () => { saveReviewedResult (reset) } const saveAndLearn = () => { resetExtraQuestionState () saveReviewedResult (() => setPhase ('learned')) } const restartFromQuestionSuggestion = () => { if (savedGameId !== null) { reset () return } saveReviewedResult (reset) } const submitQuestionSuggestion = () => { const questionText = questionSuggestion.trim () if ( !(questionText) || questionSuggestionMutation.isPending || questionSuggestionCount >= maxQuestionSuggestionsPerGame ) return saveReviewedResult (gekanatorGameId => { questionSuggestionMutation.mutate ({ gekanatorGameId, questionText, answer: questionSuggestionAnswer }) }) } const rejectGuess = () => { if (!(displayedGuess)) return setLastRejectedGuessId (displayedGuess.id) if (answers.length >= hardMaxQuestions) { setSelectingCorrectPost (true) return } setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id])) setActiveGuessId (null) setSearch ('') setSelectingCorrectPost (false) 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) setSoftenedQuestionIds (snapshot.softenedQuestionIds) setAskedQuestionBank (snapshot.askedQuestionBank) setSearch (snapshot.search) setSelectingCorrectPost (snapshot.selectingCorrectPost) setRejectedPostIds (snapshot.rejectedPostIds) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastRejectedGuessId (snapshot.lastRejectedGuessId) setActiveGuessId (snapshot.activeGuessId) setReviewGuessedPostId (snapshot.reviewGuessedPostId) setReviewCorrectPostId (snapshot.reviewCorrectPostId) setHistory (history.slice (0, -1)) } const continueGame = () => { setSearch ('') setSelectingCorrectPost (false) const recovered = recoverQuestionState ({ nextAnswers: answers, nextAskedIds: askedIds, nextAskedQuestionBank: askedQuestionBank, nextSoftenedQuestionIds: softenedQuestionIds, nextRejectedPostIds: rejectedPostIds }) setSoftenedQuestionIds (recovered.softenedQuestionIds) setScores (recovered.scores) const nextQuestion = chooseQuestion ({ posts: recovered.eligiblePosts.length > 1 ? recovered.eligiblePosts : nonRejectedPosts, questions: recovered.scoringQuestions, scores: recovered.scores, answers, askedIds, gameSeed }) if (nextQuestion) { setPhase ('question') return } setActiveGuessId (guess?.id ?? null) setPhase ('guess') } const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { setSaved (false) setSavedGameId (null) resetExtraQuestionState () setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) } const selectCorrectPost = (post: Post) => { if (phase === 'review') { setSaved (false) setSavedGameId (null) resetExtraQuestionState () setReviewCorrectPostId (post.id) setSelectingCorrectPost (false) setSearch ('') return } finishGame (post.id) } 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 loadExtraQuestions = async (gameId: number) => { extraQuestionAnswersMutation.reset () setExtraQuestionState ('loading') setExtraQuestions ([]) setExtraQuestionAnswers ({ }) setPhase ('extra_questions') try { const questions = await queryClient.fetchQuery ({ queryKey: gekanatorKeys.extraQuestions (gameId), queryFn: () => fetchGekanatorExtraQuestions (gameId) }) setExtraQuestions (questions) setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty') } catch { setExtraQuestionState ('empty') } } const startExtraQuestions = () => { if (reviewCorrectPostId === null || saveMutation.isPending) return saveReviewedResult (gameId => { void loadExtraQuestions (gameId) }) } const answerExtraQuestion = ( questionId: number, value: GekanatorAnswerValue, ) => { setExtraQuestionAnswers ({ ...extraQuestionAnswers, [String (questionId)]: value }) } const saveExtraQuestions = () => { if ( savedGameId === null || extraQuestionAnswersMutation.isPending || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) ) return extraQuestionAnswersMutation.mutate ({ gameId: savedGameId, answers: extraQuestions.map (question => ({ questionId: question.id, answer: extraQuestionAnswers[String (question.id)] })) }) } const dialogue = phase === 'learned' && resultWon ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = !(introLoading) && acceptedQuestionsFetched && posts.length > 0 && !(error) && !(acceptedQuestionsError) return ( {`グカネータ | ${ SITE_TITLE }`}

おたのしみ

グカネータ

洗澡鹿

{dialogue}

{introLoading && (

{phase === 'intro' ? '投稿を読み込んでゐます...' : '前回のグカネータ状態を復元してゐます...'}

)} {(Boolean (error) || Boolean (acceptedQuestionsError)) &&

グカネータの質問データを読み込めませんでした.

} {phase === 'intro' && readyToStart && ( )} {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 && ( )}
)} {!(isLoading) && phase === 'question' && !(currentQuestion) && (

もう十分わかった。

)} {phase === 'guess' && displayedGuess && (

これを想像してゐたね?

{history.length > 0 && ( )}
{saveMutation.isError && (

記録できませんでした。通信状態を確認してもう一度試して。

)}
)} {phase === 'continue' && (

続けますか?

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

ゲーム終了

グカカカカwwwww

{reviewGuessedPost && (
推測した投稿
)}
正解の投稿
{reviewCorrectPost ? :

正解投稿を選んでください。

}
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (

判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}

)} {saveMutation.isError && (

記録できませんでした。通信状態を確認してもう一度試して。

)}
)} {phase === 'review' && (

保存前確認

今回の結果を確認してね。

{reviewGuessedPost && (
推測した投稿
)}
正解の投稿
{reviewCorrectPost ? :

正解投稿を選んでください。

}
質問と回答
{answers.map ((answer, index) => { const expectedAnswer = expectedAnswerFor ( scoringQuestionById.get (answer.questionId), reviewCorrectPost) return (
質問 {index + 1}
{answer.questionText}
グカネータ判定: {expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
実際の回答: {answerLabelFor (answer.originalAnswer)}
) })}
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (

判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}

)} {saveMutation.isError && (

記録できませんでした。通信状態を確認してもう一度試して。

)}
)} {phase === 'question_suggestion' && (

質問追加

どんな質問なら見分けられさう?

追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}