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 { allConcreteAnswerOptionsExhausted, candidatePostsFor, hardFilteredPostsForAnswer, recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' import { isQuestionHardFilteredAfterAnswers, monthForCondition } from '@/lib/gekanatorQuestionFilters' 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 { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery' 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 recoveredCandidatePosts: Map recoveryStepCount: number askedQuestionBank: GekanatorQuestion[] search: string selectingCorrectPost: boolean rejectedPostIds: Set lastGuessQuestionCount: number lastRejectedGuessId: number | null winningRunTargetId: number | null winningRunStartAnswerCount: number | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null } type StoredGekanatorGame = { phase: Phase scores: [number, number][] answers: GekanatorAnswerLog[] askedIds: string[] softenedQuestionIds: string[] recoveredCandidatePosts?: RecoveredCandidatePost[] recoveryStepCount?: number askedQuestionBank?: StoredGekanatorQuestion[] askedQuestionBankIds?: string[] search: string selectingCorrectPost: boolean saved: boolean resultWon: boolean | null rejectedPostIds: number[] lastGuessQuestionCount: number lastRejectedGuessId: number | null winningRunTargetId?: number | null winningRunStartAnswerCount?: 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 winningRunQuestionLimit = 3 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)), recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, 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 recoveredCandidateMapFromStored = ( items: RecoveredCandidatePost[], ): Map => new Map (items.map (item => [item.postId, item.answerCountAtRecovery])) const storedRecoveredCandidatesFromMap = ( recoveredCandidatePosts: Map, ): RecoveredCandidatePost[] => [...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({ postId, answerCountAtRecovery })) 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 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 nextPosts = hardFilteredPostsForAnswer ({ posts, question, answer }) if (nextPosts.length === 0) return { answer, top: null, candidateCount: 0, effectiveCandidates: 0, entropy: 0 } 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 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 (isQuestionHardFilteredAfterAnswers (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 directWinningRunExampleAnswerFor = ( question: GekanatorQuestion, targetPost: Post, ): GekanatorAnswerValue | null => question.kind !== 'post_similarity' ? null : question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null const winningRunPriorityFor = ( question: GekanatorQuestion, expected: GekanatorAnswerValue, targetPost: Post, ): number | null => { if (question.kind === 'post_similarity') { const directAnswer = directWinningRunExampleAnswerFor (question, targetPost) if (directAnswer === null) return null if (expected === 'yes') return 1 if (expected === 'no') return 3 return null } if (expected === 'yes') return 0 if (expected === 'no') return 2 return null } const chooseWinningRunQuestion = ({ posts, fallbackPosts, questions, targetPost, scores, answers, askedIds, gameSeed, }: { posts: Post[] fallbackPosts: Post[] questions: GekanatorQuestion[] targetPost: Post scores: Map answers: GekanatorAnswerLog[] askedIds: Set gameSeed: string }): GekanatorQuestion | null => { const ranked = questions .filter (question => { if (askedIds.has (question.id)) return false if (isQuestionHardFilteredAfterAnswers (question, answers)) return false const expected = expectedAnswerForQuestion (question, targetPost) return expected !== null && expected !== 'unknown' }) .map (question => { const expected = expectedAnswerForQuestion (question, targetPost) const priority = expected === null ? null : winningRunPriorityFor (question, expected, targetPost) if (priority === null) return null const yesCount = posts.filter (post => question.test (post)).length const matchingCount = expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount return { question, priority, matchingCount } }) .filter ((item): item is { question: GekanatorQuestion priority: number matchingCount: number } => item !== null) .sort ((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority if (a.matchingCount !== b.matchingCount) return a.matchingCount - b.matchingCount if (a.question.priorityWeight !== b.question.priorityWeight) return b.question.priorityWeight - a.question.priorityWeight return a.question.id.localeCompare (b.question.id) }) if (ranked.length > 0) return ranked[0]?.question ?? null return chooseQuestion ({ posts: fallbackPosts.length > 0 ? fallbackPosts : posts, questions, scores, answers, askedIds, gameSeed }) } const isWinningRunActive = ( winningRunTargetId: number | null, winningRunStartAnswerCount: number | null, ): boolean => winningRunTargetId !== null && winningRunStartAnswerCount !== null const winningRunQuestionCount = ( answers: GekanatorAnswerLog[], winningRunStartAnswerCount: number | null, ): number => winningRunStartAnswerCount === null ? 0 : Math.max (0, answers.length - winningRunStartAnswerCount) 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 [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState> ( () => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? [])) const [recoveryStepCount, setRecoveryStepCount] = useState ( storedGame?.recoveryStepCount ?? 0) 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 [winningRunTargetId, setWinningRunTargetId] = useState ( storedGame?.winningRunTargetId ?? null) const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] = useState (storedGame?.winningRunStartAnswerCount ?? 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], recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts), recoveryStepCount, askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion), askedQuestionBankIds: storedAskedQuestionBankIds, search, selectingCorrectPost, saved, resultWon, rejectedPostIds: [...rejectedPostIds], lastGuessQuestionCount, lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, 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, recoveredCandidatePosts, recoveryStepCount, askedQuestionBank, storedAskedQuestionBankIds, search, selectingCorrectPost, saved, resultWon, rejectedPostIds, lastGuessQuestionCount, lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, savedGameId, gameSeed, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, extraQuestions, extraQuestionAnswers, extraQuestionState]) const eligiblePosts = useMemo ( () => candidatePostsFor ({ posts, questions: askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts }), [posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts]) 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 availablePosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) const winningRunTargetPost = useMemo ( () => winningRunTargetId === null ? null : posts.find (post => post.id === winningRunTargetId) ?? null, [posts, winningRunTargetId]) const winningRunQuestionsAsked = winningRunQuestionCount ( answers, winningRunStartAnswerCount) const winningRunActive = isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) && winningRunQuestionsAsked < winningRunQuestionLimit && eligiblePosts.length === 1 && eligiblePosts[0]?.id === winningRunTargetId && winningRunTargetPost !== null const questionPosts = eligiblePosts.length > 1 || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess ? eligiblePosts : availablePosts 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 = winningRunActive && winningRunTargetPost ? chooseWinningRunQuestion ({ posts, fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, questions: scoringQuestions, targetPost: winningRunTargetPost, scores, answers, askedIds, gameSeed }) : 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 : availablePosts 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 ()) setRecoveredCandidatePosts (new Map ()) setRecoveryStepCount (0) setAskedQuestionBank ([]) setSearch ('') setSelectingCorrectPost (false) setSaved (false) setResultWon (null) setRejectedPostIds (new Set ()) setLastGuessQuestionCount (0) setLastRejectedGuessId (null) setWinningRunTargetId (null) setWinningRunStartAnswerCount (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, nextRecoveredCandidatePosts, nextRecoveryStepCount, allowPreQuestionRecovery, }: { nextAnswers: GekanatorAnswerLog[] nextAskedIds: Set nextAskedQuestionBank: GekanatorQuestion[] nextSoftenedQuestionIds: Set nextRejectedPostIds: Set nextRecoveredCandidatePosts: Map nextRecoveryStepCount: number allowPreQuestionRecovery?: boolean }) => { let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds) let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts) let recoveredStepCount = nextRecoveryStepCount const answerCountAtRecovery = allowPreQuestionRecovery ? nextAnswers.length : Math.max (nextAnswers.length - 1, 0) let recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) let recoveredEligiblePosts = candidatePostsFor ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts }) let recoveredScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions ( recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), ...acceptedQuestions, ...nextAskedQuestionBank]) const refreshRecoveredState = () => { recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) recoveredEligiblePosts = candidatePostsFor ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts }) recoveredScoringQuestions = mergeQuestions ([ ...buildGekanatorQuestions ( recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), ...acceptedQuestions, ...nextAskedQuestionBank]) } const needsPreQuestionRecovery = () => { if (!(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0) return false const nextQuestion = chooseQuestion ({ posts: recoveredEligiblePosts, questions: recoveredScoringQuestions, scores: recoveredScores, answers: nextAnswers, askedIds: nextAskedIds, gameSeed }) return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion) } while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ()) { const recoveredPosts = recoverCandidatePosts ({ posts, scores: recoveredScores, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts, eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)), answerCountAtRecovery, recoveryStepCount: recoveredStepCount }) if (recoveredPosts) { recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts recoveredStepCount = recoveredPosts.recoveryStepCount refreshRecoveredState () if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ())) break } if ( recoveredEligiblePosts.length > 0 || nextAnswers.length >= hardMaxQuestions ) break const softened = softenNextQuestionIds ({ questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds }) if (!(softened)) break recoveredSoftenedQuestionIds = softened refreshRecoveredState () } return { softenedQuestionIds: recoveredSoftenedQuestionIds, recoveredCandidatePosts, recoveryStepCount: recoveredStepCount, 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), recoveredCandidatePosts: new Map (recoveredCandidatePosts), recoveryStepCount, askedQuestionBank: [...askedQuestionBank], search, selectingCorrectPost, rejectedPostIds: new Set (rejectedPostIds), lastGuessQuestionCount, lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, 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, nextRecoveredCandidatePosts: recoveredCandidatePosts, nextRecoveryStepCount: recoveryStepCount }) const nextSoftenedQuestionIds = recovered.softenedQuestionIds const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts const nextScores = recovered.scores const nextEligiblePosts = recovered.eligiblePosts const currentWinningRunActive = isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) const nextWinningRunTargetId = nextEligiblePosts.length === 1 ? nextEligiblePosts[0]?.id ?? null : null const nextWinningRunStartAnswerCount = nextWinningRunTargetId === null ? null : currentWinningRunActive && winningRunTargetId === nextWinningRunTargetId && winningRunStartAnswerCount !== null ? winningRunStartAnswerCount : nextAnswers.length setScores (nextScores) setAskedIds (nextAskedIds) setSoftenedQuestionIds (nextSoftenedQuestionIds) setRecoveredCandidatePosts (nextRecoveredCandidatePosts) setRecoveryStepCount (recovered.recoveryStepCount) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) setWinningRunTargetId (nextWinningRunTargetId) setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) const nextGuessablePosts = nextEligiblePosts.length > 0 ? nextEligiblePosts : availablePosts 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 winningRunContinues = nextWinningRunTargetId !== null && nextWinningRunStartAnswerCount !== null && nextEligiblePosts.length === 1 && winningRunQuestionCount ( nextAnswers, nextWinningRunStartAnswerCount) < winningRunQuestionLimit 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 || ( !(winningRunContinues) && (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])) setRecoveredCandidatePosts ( new Map ( [...recoveredCandidatePosts.entries ()].filter ( ([postId]) => postId !== displayedGuess.id))) setWinningRunTargetId (null) setWinningRunStartAnswerCount (null) 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) setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts) setRecoveryStepCount (snapshot.recoveryStepCount) setAskedQuestionBank (snapshot.askedQuestionBank) setSearch (snapshot.search) setSelectingCorrectPost (snapshot.selectingCorrectPost) setRejectedPostIds (snapshot.rejectedPostIds) setLastGuessQuestionCount (snapshot.lastGuessQuestionCount) setLastRejectedGuessId (snapshot.lastRejectedGuessId) setWinningRunTargetId (snapshot.winningRunTargetId) setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount) 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, nextRecoveredCandidatePosts: recoveredCandidatePosts, nextRecoveryStepCount: recoveryStepCount, allowPreQuestionRecovery: true }) setSoftenedQuestionIds (recovered.softenedQuestionIds) setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) setRecoveryStepCount (recovered.recoveryStepCount) setScores (recovered.scores) const nextWinningRunTargetId = recovered.eligiblePosts.length === 1 ? recovered.eligiblePosts[0]?.id ?? null : null const nextWinningRunStartAnswerCount = nextWinningRunTargetId === null ? null : isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) && winningRunTargetId === nextWinningRunTargetId && winningRunStartAnswerCount !== null ? winningRunStartAnswerCount : answers.length const nextWinningRunTargetPost = nextWinningRunTargetId === null ? null : posts.find (post => post.id === nextWinningRunTargetId) ?? null const recoveredGuessablePosts = recovered.eligiblePosts.length > 0 ? recovered.eligiblePosts : availablePosts const nextQuestion = nextWinningRunTargetPost && nextWinningRunStartAnswerCount !== null && winningRunQuestionCount ( answers, nextWinningRunStartAnswerCount) < winningRunQuestionLimit ? chooseWinningRunQuestion ({ posts, fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, questions: recovered.scoringQuestions, targetPost: nextWinningRunTargetPost, scores: recovered.scores, answers, askedIds, gameSeed }) : chooseQuestion ({ posts: recovered.eligiblePosts.length > 1 ? recovered.eligiblePosts : availablePosts, questions: recovered.scoringQuestions, scores: recovered.scores, answers, askedIds, gameSeed }) setWinningRunTargetId (nextWinningRunTargetId) setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) if (nextQuestion) { setPhase ('question') return } setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.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') const nonce = createGameSeed () try { const questions = await queryClient.fetchQuery ({ queryKey: gekanatorKeys.extraQuestions (gameId, nonce), queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) }) 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) useEffect (() => { if ( phase !== 'question' || isLoading || acceptedQuestionsLoading ) return const winningRunFinished = winningRunTargetId !== null && winningRunStartAnswerCount !== null && winningRunQuestionCount (answers, winningRunStartAnswerCount) >= winningRunQuestionLimit && eligiblePosts.length === 1 && eligiblePosts[0]?.id === winningRunTargetId const nextGuess = displayedGuess ?? guess if (currentQuestion || (!(winningRunFinished) && !(nextGuess))) return setActiveGuessId ((winningRunFinished ? guess : nextGuess)?.id ?? null) setLastGuessQuestionCount (answers.length) setPhase ('guess') }, [ phase, currentQuestion, guess, displayedGuess, answers, eligiblePosts, winningRunTargetId, winningRunStartAnswerCount, isLoading, acceptedQuestionsLoading]) 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 && ( )}
)} {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}