import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, 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 { expectedAnswerForQuestion, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, isLearnedSemanticQuestion, learnedSemanticSideForPost, normalizeTitleLengthCondition, questionIdForCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, storeGekanatorQuestion, titleLengthMinimumForCondition } from '@/lib/gekanator' import { 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 { Transition } from 'framer-motion' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorExtraQuestion, GekanatorQuestionPurpose, GekanatorQuestionCondition, GekanatorQuestionKind, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' import type { RecoveredCandidatePost, RecoveredCandidateState, } from '@/lib/gekanatorCandidateRecovery' import type { Post, User } 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 guessReason: GuessReason | 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 guessReason?: GuessReason | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null savedGameId: number | null learnedExampleCount?: number | null gameSeed?: string questionSuggestionEntryMode?: 'search' | 'new' questionSuggestionSearch?: string questionSuggestionSelectedId?: number | null questionSuggestion: string questionSuggestionAnswer: GekanatorAnswerValue questionSuggestionCount?: number extraQuestions?: GekanatorExtraQuestion[] extraQuestionAnswers?: Record extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' } type RecentGameSummary = { correctPostId: number firstQuestionId: string | null savedAt: number } type BackgroundMotionMode = 'on' | 'calm' | 'off' type GuessReason = | 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' | 'question_generation_stalled' type QuestionMode = | 'winning_run' | 'normal' | null type QuestionSelection = { question: GekanatorQuestion questionPurpose: GekanatorQuestionPurpose effectiveQuestion: boolean learningQuestion: boolean } type QuestionBuildMode = | 'split' | 'confirmation' type QuestionSuggestionEntryMode = | 'search' | 'new' type MascotState = | 'idle' | 'thinking_far' | 'thinking_mid' | 'thinking_near' | 'confident' | 'celebrate' | 'failed' 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 minQuestionsBeforeCertainGuess = 25 const hardMaxQuestions = 80 const winningRunQuestionLimit = 3 const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1' const backgroundMotionStorageKey = 'gekanator:background-motion:v1' const maxStoredRecentGames = 12 const specialOriginalMonthDayLabels: Record = { '1-1': '元日', '12-31': '大晦日', '12-3': '12月3日', '5-29': '5月29日' } const mascotAssetByState: Record = { idle: '/assets/gekanator/mascot-idle.png', thinking_far: '/assets/gekanator/mascot-thinking-far.png', thinking_mid: '/assets/gekanator/mascot-thinking-mid.png', thinking_near: '/assets/gekanator/mascot-thinking-near.png', confident: '/assets/gekanator/mascot-confident.png', celebrate: '/assets/gekanator/mascot-celebrate.png', failed: '/assets/gekanator/mascot-failed.png' } const mascotAltByState: Record = { idle: '待機する洗澡鹿', thinking_far: '遠くを見つめる洗澡鹿', thinking_mid: '考え込む洗澡鹿', thinking_near: '見通しが立ってきた洗澡鹿', confident: '見通した顔の洗澡鹿', celebrate: 'ご満悦の洗澡鹿', failed: 'しょんぼりした洗澡鹿' } 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 => { const questionMode = answer.questionMode === 'winning_run' || answer.questionMode === 'normal' ? answer.questionMode : undefined const questionCondition = answer.questionCondition ? normalizeTitleLengthCondition (answer.questionCondition) : undefined return { ...answer, questionId: normalizeStoredQuestionId (answer.questionId, answer.questionCondition), questionMode, questionCondition } }), askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), softenedQuestionIds: (game.softenedQuestionIds .map (questionId => normalizeStoredQuestionId (questionId))), recoveredCandidatePosts: (game.recoveredCandidatePosts ?? []).map (item => ({ ...item, scoreAtRecovery: item.scoreAtRecovery ?? ( new Map (game.scores).get (item.postId) ?? 0) })), recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, learnedExampleCount: game.learnedExampleCount ?? null, questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search', questionSuggestionSearch: game.questionSuggestionSearch ?? '', questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? 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 = 2_166_136_261 for (let i = 0; i < value.length; ++i) { hash ^= value.charCodeAt (i) hash = Math.imul (hash, 16_777_619) } return hash >>> 0 } const deterministicUnitFloat = (seed: string): number => hashString (seed) / 4_294_967_295 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 loadRecentGames = (): RecentGameSummary[] => { try { const raw = localStorage.getItem (recentGamesStorageKey) if (!(raw)) return [] const parsed = JSON.parse (raw) if (!(Array.isArray (parsed))) return [] return ( parsed .filter ((item): item is RecentGameSummary => ( typeof item === 'object' && item != null && Number.isInteger ((item as RecentGameSummary).correctPostId) && (((item as RecentGameSummary).firstQuestionId == null) || typeof (item as RecentGameSummary).firstQuestionId === 'string') && Number.isFinite ((item as RecentGameSummary).savedAt))) .sort ((a, b) => b.savedAt - a.savedAt) .slice (0, maxStoredRecentGames)) } catch { return [] } } const storeRecentGameSummary = ( summary: RecentGameSummary): RecentGameSummary[] => { const next = [summary, ...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt && !(item.correctPostId === summary.correctPostId && (item.firstQuestionId === summary.firstQuestionId))))] .slice (0, maxStoredRecentGames) try { localStorage.setItem (recentGamesStorageKey, JSON.stringify (next)) } catch { return next } return next } const loadBackgroundMotionMode = (): BackgroundMotionMode => { const fallbackMode = 'on' try { const raw = localStorage.getItem (backgroundMotionStorageKey) if (raw === 'off' || raw === 'calm' || raw === 'on') return raw return fallbackMode } catch { return fallbackMode } } const resettableExtraQuestionState = (): { extraQuestions: GekanatorExtraQuestion[] extraQuestionAnswers: Record extraQuestionState: 'idle' } => ( { extraQuestions: [], extraQuestionAnswers: { }, extraQuestionState: 'idle' }) const recoveredCandidateMapFromStored = ( items: RecoveredCandidatePost[], scores: [number, number][]): Map => { const storedScores = new Map (scores) return new Map (items.map (item => [item.postId, { answerCountAtRecovery: item.answerCountAtRecovery, scoreAtRecovery: item.scoreAtRecovery ?? storedScores.get (item.postId) ?? 0 }])) } const storedRecoveredCandidatesFromMap = ( recoveredCandidatePosts: Map): RecoveredCandidatePost[] => [...recoveredCandidatePosts.entries ()] .map (([postId, recoveredCandidate]) => ({ postId, answerCountAtRecovery: recoveredCandidate.answerCountAtRecovery, scoreAtRecovery: recoveredCandidate.scoreAtRecovery })) const learnedSemanticMinKnownRatio = .08 const learnedSemanticMinKnownCount = 4 const learnedSemanticMinSideCount = 2 const targetEffectiveUserSuggestedQuestionRatio = 1 / 3 const targetLearningUserSuggestedQuestionRatio = 1 / 3 const targetTotalUserSuggestedQuestionRatio = 2 / 3 const scoreDropActivationThreshold = 20 const learningUserSuggestedScoreWeight = .33 const baseDeltaForAnswer = (answer: GekanatorAnswerValue): number => { switch (answer) { case 'yes': return 4 case 'no': return -4 case 'partial': return 2 case 'probably_no': return -2 case 'unknown': return 0 } } const distributionEntropy = (weights: number[]): number => weights.reduce ((sum, weight) => weight <= 0 ? sum : sum - weight * Math.log2 (weight), 0) const questionCategoryPenalty = ( question: GekanatorQuestion, answerCount: number, repeatPenalty: number): number => { const earlyFactor = Math.max (0, (3 - answerCount) / 3) const titleLengthPenalty = (() => { if (titleLengthMinimumForCondition (question.condition) == null) return 0 return (answerCount === 0 ? 8 : 3.5) * earlyFactor }) () switch (question.kind) { case 'tag': return -2.8 * earlyFactor + repeatPenalty case 'post_similarity': return -3.2 * earlyFactor + repeatPenalty case 'title': return 3.4 * earlyFactor + titleLengthPenalty + repeatPenalty case 'source': case 'original_date': return 2.4 * earlyFactor + repeatPenalty default: return repeatPenalty } } const relatedPostIdsOf = (post: Post): number[] => { const siblingPosts = Object.values (post.siblingPosts ?? { }).flat () return [...new Set ([ ...(post.related ?? []).map (related => related.id), ...(post.parentPosts ?? []).map (parent => parent.id), ...(post.childPosts ?? []).map (child => child.id), ...siblingPosts.map (sibling => sibling.id)])] } const userPriorWeightsFor = ( posts: Post[], recentGames: RecentGameSummary[]): Map => { const postById = new Map (posts.map (post => [post.id, post])) const weights = new Map () const addWeight = (postId: number, weight: number) => { if (!(postById.has (postId)) || weight <= 0) return weights.set (postId, (weights.get (postId) ?? 0) + weight) } recentGames.slice (0, 6).forEach ((game, index) => { const baseWeight = Math.max (.24, 1 - index * .18) addWeight (game.correctPostId, baseWeight) const correctPost = postById.get (game.correctPostId) if (!(correctPost)) return relatedPostIdsOf (correctPost).forEach (postId => addWeight (postId, baseWeight * .45)) }) return weights } const answerWeightFor = ( questionId: string, softenedQuestionIds: Set): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1 const scoreWeightForAnswer = ( answer: GekanatorAnswerLog, softenedQuestionIds: Set): number => answerWeightFor (answer.questionId, softenedQuestionIds) * ( answer.questionPurpose === 'learning_user_suggested' ? learningUserSuggestedScoreWeight : 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 } type GekanatorMatchIndex = Map> type GekanatorQuestionMaterialIndex = { postById: Map tagKeysByPostId: Map postIdsByTagKey: Map> titleTermsByPostId: Map postIdsByTitleTerm: Map> hostByPostId: Map postIdsByHost: Map> originalYearByPostId: Map postIdsByOriginalYear: Map> originalMonthByPostId: Map postIdsByOriginalMonth: Map> originalMonthDayByPostId: Map postIdsByOriginalMonthDay: Map> titleLengthByPostId: Map titleAsciiPostIds: Set titleLengthThresholdCache: Map> } const titleTermPattern = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu const addPostIdToIndex = ( index: Map>, key: K, postId: number) => { const current = index.get (key) if (current) { current.add (postId) return } index.set (key, new Set ([postId])) } const buildMaterialIndex = ( posts: Post[]): GekanatorQuestionMaterialIndex => { const postById = new Map () const tagKeysByPostId = new Map () const postIdsByTagKey = new Map> () const titleTermsByPostId = new Map () const postIdsByTitleTerm = new Map> () const hostByPostId = new Map () const postIdsByHost = new Map> () const originalYearByPostId = new Map () const postIdsByOriginalYear = new Map> () const originalMonthByPostId = new Map () const postIdsByOriginalMonth = new Map> () const originalMonthDayByPostId = new Map () const postIdsByOriginalMonthDay = new Map> () const titleLengthByPostId = new Map () const titleAsciiPostIds = new Set () posts.forEach (post => { postById.set (post.id, post) const tagKeys = post.tags .filter (tag => tag.category !== 'meta' && !(tag.name.includes ('タグ希望')) && !(tag.name.includes ('bot操作'))) .map (tag => `${ tag.category }:${ tag.name }`) tagKeysByPostId.set (post.id, tagKeys) tagKeys.forEach (key => addPostIdToIndex (postIdsByTagKey, key, post.id)) const titleTerms = Array.from ( new Set ((post.title ?? '').match (titleTermPattern) ?? [])) titleTermsByPostId.set (post.id, titleTerms) titleTerms.forEach (term => addPostIdToIndex (postIdsByTitleTerm, term, post.id)) const host = (() => { try { return new URL (post.url).hostname.replace (/^www\./, '') } catch { return null } }) () hostByPostId.set (post.id, host) if (host) addPostIdToIndex (postIdsByHost, host, post.id) const originalValue = post.originalCreatedFrom || post.originalCreatedBefore const date = originalValue ? new Date (originalValue) : null const validDate = date && !(Number.isNaN (date.getTime ())) ? date : null const originalYear = validDate?.getFullYear () ?? null const originalMonth = validDate ? validDate.getMonth () + 1 : null const originalMonthDay = validDate ? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }` : null originalYearByPostId.set (post.id, originalYear) originalMonthByPostId.set (post.id, originalMonth) originalMonthDayByPostId.set (post.id, originalMonthDay) if (originalYear != null) addPostIdToIndex (postIdsByOriginalYear, originalYear, post.id) if (originalMonth != null) addPostIdToIndex (postIdsByOriginalMonth, originalMonth, post.id) if (originalMonthDay != null) addPostIdToIndex (postIdsByOriginalMonthDay, originalMonthDay, post.id) const titleLength = post.title?.length ?? 0 titleLengthByPostId.set (post.id, titleLength) if (/[A-Za-z0-9]/.test (post.title ?? '')) titleAsciiPostIds.add (post.id) }) return { postById, tagKeysByPostId, postIdsByTagKey, titleTermsByPostId, postIdsByTitleTerm, hostByPostId, postIdsByHost, originalYearByPostId, postIdsByOriginalYear, originalMonthByPostId, postIdsByOriginalMonth, originalMonthDayByPostId, postIdsByOriginalMonthDay, titleLengthByPostId, titleAsciiPostIds, titleLengthThresholdCache: new Map> () } } const indexedQuestionTextForTag = (key: string): string => { const [category, ...rest] = key.split (':') const name = rest.join (':') const label = category === 'nico' ? name.replace (/^nico:/, '') : name switch (category) { case 'deerjikist': return `${ label }(敬称略)がコンテンツ作成に関与した?` case 'meme': return `『${ label }』に関係しそう?` case 'character': return `${ label }が登場する?` case 'material': return `${ label }が使われている?` case 'nico': return `ニコニコに「${ label }」というタグがついている?` default: return `${ label }の要素が含まれる?` } } const specialOriginalMonthDayLabelFor = (monthDay: string): string | null => specialOriginalMonthDayLabels[monthDay] ?? null const originalDateQuestionTextFor = ( condition: Extract< GekanatorQuestionCondition, { type: 'original-year' | 'original-month' | 'original-month-day' } >): string => { switch (condition.type) { case 'original-year': return `オリジナルの投稿年は ${ condition.year } 年?` case 'original-month': return `オリジナルの投稿月は ${ condition.month } 月?` case 'original-month-day': { const label = specialOriginalMonthDayLabelFor (condition.monthDay) if (label) return `${ label }に投稿された?` const [month, day] = condition.monthDay.split ('-') return `オリジナルの投稿日は ${ month } 月 ${ day } 日?` } } } const humanPriorityOffsetFor = (question: GekanatorQuestion): number => { switch (question.kind) { case 'tag': return -6 case 'source': return -2.5 case 'post_similarity': if (question.source === 'user_suggested' || question.source === 'admin_curated') return -3.5 return -1.5 case 'original_date': switch (question.condition.type) { case 'original-year': return -2 case 'original-month-day': return specialOriginalMonthDayLabelFor (question.condition.monthDay) ? -1.4 : 6 case 'original-month': return 7 default: return 0 } case 'title': switch (question.condition.type) { case 'title-contains': return -1.8 case 'title-has-ascii': return 10 case 'title-length-at-least': case 'title-length-greater-than': return 9 default: return 0 } default: return 0 } } const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:')) const isUserSuggestedLearnedSemanticQuestion = ( question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question) type LearnedSemanticCandidateStats = { positiveIds: Set negativeIds: Set unknownIds: Set positiveCount: number negativeCount: number unknownCount: number knownCount: number } const learnedSemanticStatsForCandidateIds = ( { candidateIds, posts, question }: { candidateIds: number[] posts: Post[] question: GekanatorQuestion }): LearnedSemanticCandidateStats => { const candidateIdSet = new Set (candidateIds) const positiveIds = new Set () const negativeIds = new Set () const unknownIds = new Set () posts.forEach (post => { if (!(candidateIdSet.has (post.id))) return const side = learnedSemanticSideForPost (question, post) if (side === 'positive') { positiveIds.add (post.id) return } if (side === 'negative') { negativeIds.add (post.id) return } unknownIds.add (post.id) }) return { positiveIds, negativeIds, unknownIds, positiveCount: positiveIds.size, negativeCount: negativeIds.size, unknownCount: unknownIds.size, knownCount: positiveIds.size + negativeIds.size } } const learnedSemanticQuestionIsEffectiveForCandidateIds = ( { candidateIds, posts, question }: { candidateIds: number[] posts: Post[] question: GekanatorQuestion }): boolean => { if (!(isUserSuggestedLearnedSemanticQuestion (question))) return false const stats = learnedSemanticStatsForCandidateIds ({ candidateIds, posts, question }) const minimumKnownCount = Math.max ( learnedSemanticMinKnownCount, Math.floor (candidateIds.length * learnedSemanticMinKnownRatio)) return stats.knownCount >= minimumKnownCount && stats.positiveCount >= learnedSemanticMinSideCount && stats.negativeCount >= learnedSemanticMinSideCount } const directSemanticAnswerForPost = ( question: GekanatorQuestion, post: Post): GekanatorAnswerValue | null => { const direct = question.exampleAnswers?.[String (post.id) as `${ number }`] if (direct) return direct if (question.condition.type === 'post-similarity' && question.condition.postId === post.id) return question.condition.answer return null } const learnedSemanticLearningValueForTopPosts = ( { question, learningTargetPosts, candidateIds, posts }: { question: GekanatorQuestion learningTargetPosts: Post[] candidateIds: number[] posts: Post[] }): { missingTopCount: number knownCount: number hasLearningValue: boolean } => { const missingTopCount = learningTargetPosts.filter ( post => directSemanticAnswerForPost (question, post) == null).length const knownCount = learnedSemanticStatsForCandidateIds ({ candidateIds, posts, question }).knownCount return { missingTopCount, knownCount, hasLearningValue: missingTopCount > 0 } } const learningTargetPostsForCandidates = ({ scoredPosts, gameSeed, }: { scoredPosts: { post: Post; score: number }[] gameSeed: string }): Post[] => { const topPosts = scoredPosts .slice (0, Math.min (6, scoredPosts.length)) .map (item => item.post) const topPostIds = new Set (topPosts.map (post => post.id)) const sampledPosts = scoredPosts .filter (item => !(topPostIds.has (item.post.id))) .map (item => ({ post: item.post, weight: deterministicUnitFloat (`${ gameSeed }:learning-sample:${ item.post.id }`) })) .sort ((a, b) => a.weight - b.weight) .slice (0, Math.min (4, Math.max (0, scoredPosts.length - topPosts.length))) .map (item => item.post) return [...topPosts, ...sampledPosts] } const questionPurposeCountsFor = ( answers: GekanatorAnswerLog[]): { effectiveUserSuggestedCount: number learningUserSuggestedCount: number normalQuestionCount: number totalNormalPhaseQuestionCount: number totalUserSuggestedCount: number } => { let effectiveUserSuggestedCount = 0 let learningUserSuggestedCount = 0 let normalQuestionCount = 0 answers.forEach (answer => { if (answer.questionMode !== 'normal') return switch (answer.questionPurpose) { case 'effective_user_suggested': ++effectiveUserSuggestedCount return case 'learning_user_suggested': ++learningUserSuggestedCount return default: ++normalQuestionCount } }) return { effectiveUserSuggestedCount, learningUserSuggestedCount, normalQuestionCount, totalNormalPhaseQuestionCount: effectiveUserSuggestedCount + learningUserSuggestedCount + normalQuestionCount, totalUserSuggestedCount: effectiveUserSuggestedCount + learningUserSuggestedCount } } const learnedSemanticNarrowPenaltyForStats = ( candidateCount: number, stats: LearnedSemanticCandidateStats): number => { const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08) return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0 } const learnedSemanticScoreDeltaForExpectedAnswer = ( userAnswer: GekanatorAnswerValue, expectedAnswer: GekanatorAnswerValue | null): number => { switch (userAnswer) { case 'yes': if (expectedAnswer === 'yes' || expectedAnswer === 'partial') return 4 if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') return -4 return 0 case 'no': if (expectedAnswer === 'yes' || expectedAnswer === 'partial') return -4 if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') return 4 return 0 case 'partial': if (expectedAnswer === 'yes' || expectedAnswer === 'partial') return 2 if (expectedAnswer === 'no') return -2 if (expectedAnswer === 'probably_no') return -1 return 0 case 'probably_no': if (expectedAnswer === 'yes' || expectedAnswer === 'partial') return -2 if (expectedAnswer === 'no' || expectedAnswer === 'probably_no') return 1 return 0 case 'unknown': return 0 } } const scoreDropDeltaForRecoveredPost = ( postId: number, totalScore: number, recoveredCandidatePosts: Map): number => { const recoveredCandidate = recoveredCandidatePosts.get (postId) if (recoveredCandidate == null) return totalScore return totalScore - recoveredCandidate.scoreAtRecovery } const activeCandidateScoreDropEnabled = (scores: Map): boolean => { if (scores.size === 0) return false const maxScore = Math.max (...scores.values ()) return maxScore >= scoreDropActivationThreshold } const postPassesScoreDrop = ( { postId, scores, recoveredCandidatePosts }: { postId: number scores: Map recoveredCandidatePosts: Map }): boolean => { if (!(activeCandidateScoreDropEnabled (scores))) return true const totalScore = scores.get (postId) ?? 0 return scoreDropDeltaForRecoveredPost ( postId, totalScore, recoveredCandidatePosts) >= 0 } // `post_similarities` is the score-propagation graph, not the question kind. const questionUsesPostSimilarityPropagationGraphForScoring = ( question: GekanatorQuestion): boolean => (question.kind === 'post_similarity' && !(isUserSuggestedLearnedSemanticQuestion (question))) || (question.kind === 'tag' && question.condition.type === 'tag' && !(question.condition.key.startsWith ('nico:'))) const questionSupportsAnswerBasedHardFiltering = ( question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question)) && !(isUserSuggestedLearnedSemanticQuestion (question)) const isLearnableQuestionForUserAnswer = (question: GekanatorQuestion): boolean => question.kind === 'post_similarity' || (question.kind === 'tag' && question.condition.type === 'tag' && isLearnableTagKey (question.condition.key)) const usesLearnedTagExamples = (question: GekanatorQuestion): boolean => question.kind === 'tag' && question.condition.type === 'tag' && Boolean (question.exampleAnswers) && Object.keys (question.exampleAnswers ?? { }).length > 0 const searchedQuestionsFor = ( questions: GekanatorQuestion[], search: string): GekanatorQuestion[] => { const needle = search.trim () if (!(needle)) return [] const normalizedNeedle = needle.toLowerCase () const prefixMatches = questions.filter (question => isLearnableQuestionForUserAnswer (question) && question.text.toLowerCase ().startsWith (normalizedNeedle)) const partialMatches = questions.filter (question => isLearnableQuestionForUserAnswer (question) && !(question.text.toLowerCase ().startsWith (normalizedNeedle)) && question.text.toLowerCase ().includes (normalizedNeedle)) return [...prefixMatches, ...partialMatches].slice (0, 20) } const matchingPostIdsForCondition = ({ condition, materialIndex, }: { condition: GekanatorQuestionCondition materialIndex: GekanatorQuestionMaterialIndex }): Set | null => { switch (condition.type) { case 'tag': return materialIndex.postIdsByTagKey.get (condition.key) ?? new Set () case 'source': return materialIndex.postIdsByHost.get (condition.host) ?? new Set () case 'original-year': return materialIndex.postIdsByOriginalYear.get (condition.year) ?? new Set () case 'original-month': return materialIndex.postIdsByOriginalMonth.get (condition.month) ?? new Set () case 'original-month-day': return materialIndex.postIdsByOriginalMonthDay.get (condition.monthDay) ?? new Set () case 'title-has-ascii': return materialIndex.titleAsciiPostIds case 'title-contains': return materialIndex.postIdsByTitleTerm.get (condition.text) ?? new Set () case 'title-length-at-least': case 'title-length-greater-than': { const threshold = titleLengthMinimumForCondition (condition) if (threshold == null) return new Set () const cached = materialIndex.titleLengthThresholdCache.get (threshold) if (cached) return cached const matched = new Set () materialIndex.titleLengthByPostId.forEach ((length, postId) => { if (length >= threshold) matched.add (postId) }) materialIndex.titleLengthThresholdCache.set (threshold, matched) return matched } case 'post-similarity': return null } } type QuestionMatchResolver = { posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex } const buildGekanatorMatchIndex = ( posts: Post[], questions: GekanatorQuestion[]): GekanatorMatchIndex => new Map ( questions.map (question => [ question.id, new Set ( posts .filter (post => question.test (post)) .map (post => post.id))])) const matchingPostIdsForQuestion = ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex, }: QuestionMatchResolver): Set => { if (usesLearnedTagExamples (question)) { const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id) if (matched) return matched const computed = new Set ( posts .filter (post => question.test (post)) .map (post => post.id)) dynamicMatchIndex?.set (question.id, computed) return computed } const byCondition = matchingPostIdsForCondition ({ condition: question.condition, materialIndex }) if (byCondition != null) return byCondition const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id) if (matched) return matched const computed = new Set ( posts .filter (post => question.test (post)) .map (post => post.id)) dynamicMatchIndex?.set (question.id, computed) return computed } const positiveMatchingPostIdsForQuestion = ( resolver: QuestionMatchResolver): Set => { if (isUserSuggestedLearnedSemanticQuestion (resolver.question)) { const cached = resolver.dynamicMatchIndex?.get (resolver.question.id) if (cached) return cached const computed = new Set ( resolver.posts .filter (post => learnedSemanticSideForPost (resolver.question, post) === 'positive') .map (post => post.id)) resolver.dynamicMatchIndex?.set (resolver.question.id, computed) return computed } return matchingPostIdsForQuestion (resolver) } const matchingPostCountInIds = ({ candidateIds, candidateIdSet, posts, materialIndex, matchIndex, question, dynamicMatchIndex, }: { candidateIds: number[] candidateIdSet?: Set posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }): number => { const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) const ids = candidateIdSet ?? new Set (candidateIds) let count = 0 if (matched.size < ids.size) matched.forEach (postId => { if (ids.has (postId)) ++count }) else ids.forEach (postId => { if (matched.has (postId)) ++count }) return count } const matchingWeightInCandidates = ( { candidates, posts, materialIndex, matchIndex, question, dynamicMatchIndex }: { candidates: { post: Post; weight: number }[] posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }): number => { const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) return candidates.reduce ((sum, item) => sum + (matched.has (item.post.id) ? item.weight : 0), 0) } const signatureForCandidateIds = ( { candidateIds, posts, materialIndex, matchIndex, question, dynamicMatchIndex, }: { candidateIds: number[] posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex question: GekanatorQuestion dynamicMatchIndex?: GekanatorMatchIndex }): string => { if (isUserSuggestedLearnedSemanticQuestion (question)) { const postById = new Map (posts.map (post => [post.id, post])) return candidateIds.map (postId => { const post = postById.get (postId) ?? null const side = learnedSemanticSideForPost (question, post) if (side === 'positive') return '1' if (side === 'negative') return '0' return 'u' }).join ('') } const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) return candidateIds.map (postId => matched.has (postId) ? '1' : '0').join ('') } const postIdsForHardAnswer = ( { candidateIds, question, answer, posts, materialIndex, matchIndex, dynamicMatchIndex }: { candidateIds: number[] question: GekanatorQuestion answer: GekanatorAnswerValue posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex dynamicMatchIndex?: GekanatorMatchIndex }): number[] => { if (!(questionSupportsAnswerBasedHardFiltering (question))) return candidateIds if (answer === 'unknown' || answer === 'partial' || answer === 'probably_no') return candidateIds if (answer === 'yes') { const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) return candidateIds.filter (postId => matched.has (postId)) } if (answer === 'no') { const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) return candidateIds.filter (postId => !(matched.has (postId))) } return candidateIds } const applyQuestionAnswerDeltaToScores = ({ posts, question, answer, weight, nextScores, materialIndex, matchIndex, dynamicMatchIndex, }: { posts: Post[] question: GekanatorQuestion answer: GekanatorAnswerValue weight: number nextScores: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex dynamicMatchIndex: GekanatorMatchIndex }): void => { if (isUserSuggestedLearnedSemanticQuestion (question)) { posts.forEach (post => { const delta = learnedSemanticScoreDeltaForExpectedAnswer ( answer, expectedAnswerForQuestion (question, post)) if (delta === 0) return nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + delta * weight) }) return } const baseDelta = baseDeltaForAnswer (answer) * weight if (baseDelta === 0) return if (!(questionUsesPostSimilarityPropagationGraphForScoring (question))) { posts.forEach (post => { const delta = learnedSemanticScoreDeltaForExpectedAnswer ( answer, expectedAnswerForQuestion (question, post)) if (delta === 0) return nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) + delta * weight) }) return } const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) matched.forEach (postId => { nextScores.set ( postId, (nextScores.get (postId) ?? 0) + baseDelta) }) // `post_similarities` is the propagation graph. Directly matched posts // get only the base delta; only non-direct neighbors get `base delta * cos`. // When several matched posts point at the same neighbor, keep the largest // absolute propagated contribution instead of summing all of them. const propagatedDeltaByPostId = new Map () matched.forEach (postId => { const post = materialIndex.postById.get (postId) post?.postSimilarityEdges?.forEach (edge => { if (!Number.isFinite (edge.cos) || edge.cos <= 0) return if (matched.has (edge.targetPostId)) return const propagatedDelta = baseDelta * edge.cos const current = propagatedDeltaByPostId.get (edge.targetPostId) if (current == null || Math.abs (propagatedDelta) > Math.abs (current)) propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta) }) }) propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => { nextScores.set ( postId, (nextScores.get (postId) ?? 0) + propagatedDelta) }) } const buildIndexedQuestion = ( { condition, text, kind, priorityWeight, materialIndex }: { condition: Exclude text: string kind: GekanatorQuestionKind priorityWeight: number materialIndex: GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({ id: questionIdForCondition (condition), text, kind, condition, source: 'default', priorityWeight, test: post => (matchingPostIdsForCondition ({ condition, materialIndex }) ?? new Set ()).has (post.id) }) const rankedEntriesForCounts = ( { counts, total, cap }: { counts: Map total: number cap: number }): [T, number][] => ([...counts.entries ()] .filter (([, count]) => count > 0 && count < total) .sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1])) .slice (0, cap)) const buildQuestionsForCandidateIds = ( { candidateIds, materialIndex, acceptedQuestions, mode = 'split', confirmationPostId = null }: { candidateIds: number[] materialIndex: GekanatorQuestionMaterialIndex acceptedQuestions: GekanatorQuestion[] mode?: QuestionBuildMode confirmationPostId?: number | null }): GekanatorQuestion[] => { const total = candidateIds.length const confirmationPost = (() => { if (confirmationPostId == null) return null return materialIndex.postById.get (confirmationPostId) ?? null }) () if (mode === 'split' && total === 0) return acceptedQuestions if (mode === 'confirmation' && confirmationPost == null) return acceptedQuestions const tagCounts = new Map () const hostCounts = new Map () const yearCounts = new Map () const monthDayCounts = new Map () const titleTermCounts = new Map () const titleLengths: number[] = [] let asciiCount = 0 candidateIds.forEach (postId => { materialIndex.tagKeysByPostId.get (postId)?.forEach (key => tagCounts.set (key, (tagCounts.get (key) ?? 0) + 1)) const host = materialIndex.hostByPostId.get (postId) if (host) hostCounts.set (host, (hostCounts.get (host) ?? 0) + 1) const year = materialIndex.originalYearByPostId.get (postId) if (year != null) yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1) const monthDay = materialIndex.originalMonthDayByPostId.get (postId) if (monthDay) monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1) materialIndex.titleTermsByPostId.get (postId)?.forEach (term => titleTermCounts.set (term, (titleTermCounts.get (term) ?? 0) + 1)) const titleLength = materialIndex.titleLengthByPostId.get (postId) ?? 0 titleLengths.push (titleLength) if (materialIndex.titleAsciiPostIds.has (postId)) ++asciiCount }) const tagCap = total >= 120 ? 128 : 96 const titleTermCap = total >= 80 ? 10 : total >= 24 ? 14 : 20 const factCap = total >= 80 ? 8 : 12 const sortedLengths = [...titleLengths].sort ((a, b) => a - b) const titleLengthMedian = sortedLengths[Math.floor (sortedLengths.length / 2)] ?? 0 const questions: GekanatorQuestion[] = [] const addQuestion = (question: GekanatorQuestion | null) => { if (question) questions.push (question) } const buildDateQuestion = ( condition: Extract< GekanatorQuestionCondition, { type: 'original-year' | 'original-month' | 'original-month-day' } >): GekanatorQuestion => { const priorityWeight = (() => { if (condition.type === 'original-year') return 1.04 if (condition.type === 'original-month-day') return 1.01 return .92 }) () return buildIndexedQuestion ({ condition, text: originalDateQuestionTextFor (condition), kind: 'original_date', priorityWeight, materialIndex }) } const specialMonthDays = rankedEntriesForCounts ({ counts: monthDayCounts, total, cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null) if (mode === 'split') { rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap }) .forEach (([key]) => { addQuestion (buildIndexedQuestion ({ condition: { type: 'tag', key }, text: indexedQuestionTextForTag (key), kind: 'tag', priorityWeight: 1.08, materialIndex })) }) rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap }) .forEach (([host]) => { addQuestion (buildIndexedQuestion ({ condition: { type: 'source', host }, text: `${ host } の投稿を思い浮かべている?`, kind: 'source', priorityWeight: 1.02, materialIndex })) }) rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap }) .forEach (([year]) => { addQuestion (buildDateQuestion ({ type: 'original-year', year })) }) specialMonthDays.forEach (([monthDay]) => { addQuestion (buildDateQuestion ({ type: 'original-month-day', monthDay: String (monthDay) })) }) rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap }) .filter (([term]) => String (term).length <= 24) .forEach (([term]) => { addQuestion (buildIndexedQuestion ({ condition: { type: 'title-contains', text: String (term) }, text: `タイトルに「${ term }」が含まれる?`, kind: 'title', priorityWeight: .99, materialIndex })) }) if (titleLengthMedian > 0) addQuestion (buildIndexedQuestion ({ condition: { type: 'title-length-at-least', length: titleLengthMedian }, text: `タイトルは ${ titleLengthMedian } 文字以上?`, kind: 'title', priorityWeight: .72, materialIndex })) if (asciiCount > 0 && asciiCount < total) addQuestion (buildIndexedQuestion ({ condition: { type: 'title-has-ascii' }, text: 'タイトルに英数字が混じっている?', kind: 'title', priorityWeight: .68, materialIndex })) } else if (confirmationPost) { const targetPostId = confirmationPost.id const host = materialIndex.hostByPostId.get (targetPostId) const year = materialIndex.originalYearByPostId.get (targetPostId) const monthDay = materialIndex.originalMonthDayByPostId.get (targetPostId) const titleTerms = materialIndex.titleTermsByPostId.get (targetPostId) ?? [] const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0 const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? [] if (host) addQuestion (buildIndexedQuestion ({ condition: { type: 'source', host }, text: `${ host } の投稿を思い浮かべている?`, kind: 'source', priorityWeight: 1.02, materialIndex })) if (year != null) addQuestion (buildDateQuestion ({ type: 'original-year', year })) if (monthDay && specialOriginalMonthDayLabelFor (monthDay)) addQuestion (buildDateQuestion ({ type: 'original-month-day', monthDay })) tagKeys .slice (0, 20) .forEach (key => { addQuestion (buildIndexedQuestion ({ condition: { type: 'tag', key }, text: indexedQuestionTextForTag (key), kind: 'tag', priorityWeight: 1.08, materialIndex })) }) titleTerms .filter (term => term.length <= 24) .slice (0, 8) .forEach (term => { addQuestion (buildIndexedQuestion ({ condition: { type: 'title-contains', text: term }, text: `タイトルに「${ term }」が含まれる?`, kind: 'title', priorityWeight: .99, materialIndex })) }) if (titleLength > 0) addQuestion (buildIndexedQuestion ({ condition: { type: 'title-length-at-least', length: titleLength }, text: `タイトルは ${ titleLength } 文字以上?`, kind: 'title', priorityWeight: .72, materialIndex })) if (materialIndex.titleAsciiPostIds.has (targetPostId)) addQuestion (buildIndexedQuestion ({ condition: { type: 'title-has-ascii' }, text: 'タイトルに英数字が混じっている?', kind: 'title', priorityWeight: .68, materialIndex })) } return mergeQuestions ([...questions, ...acceptedQuestions]) } const candidatePostsForState = ({ posts, questionById, materialIndex, matchIndex, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores, }: { posts: Post[] questionById: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex answers: GekanatorAnswerLog[] softenedQuestionIds: Set rejectedPostIds: Set recoveredCandidatePosts: Map scores: Map }): Post[] => { const dynamicMatchIndex = new Map> () const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean => answer === 'yes' || answer === 'no' return posts.filter (post => { if (rejectedPostIds.has (post.id)) return false const recoveredCandidate = recoveredCandidatePosts.get (post.id) const survivesHardFiltering = answers.every ((answer, index) => { if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery) return true if (softenedQuestionIds.has (answer.questionId)) return true if (!(answerAllowsHardFilter (answer.answer))) return true const question = questionById.get (answer.questionId) const condition = answer.questionCondition ?? question?.condition if (!(condition)) return true const matched = (() => { if (question) return positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) return matchingPostIdsForCondition ({ condition, materialIndex }) }) () const useExpectedAnswer = question != null && usesLearnedTagExamples (question) if (question && !(questionSupportsAnswerBasedHardFiltering (question))) return true if (matched != null) { if (useExpectedAnswer) { const expected = expectedAnswerForQuestion (question, post) return expected == null || expected === 'unknown' || expected === answer.answer } return answer.answer === 'yes' ? matched.has (post.id) : !(matched.has (post.id)) } if (!(question)) return true const expected = expectedAnswerForQuestion (question, post) return expected == null || expected === 'unknown' || expected === answer.answer }) if (!(survivesHardFiltering)) return false return postPassesScoreDrop ({ postId: post.id, scores, recoveredCandidatePosts }) }) } const hasDiscriminatingHardSplitForQuestion = ({ candidateIds, question, posts, materialIndex, matchIndex, }: { candidateIds: number[] question: GekanatorQuestion | null posts: Post[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): boolean => { if (!(question)) return false if (isUserSuggestedLearnedSemanticQuestion (question)) return learnedSemanticQuestionIsEffectiveForCandidateIds ({ candidateIds, posts, question }) const dynamicMatchIndex = new Map> () const yesCount = matchingPostCountInIds ({ candidateIds, posts, materialIndex, matchIndex, question, dynamicMatchIndex }) const noCount = candidateIds.length - yesCount return yesCount > 0 && noCount > 0 } const recalculateScores = ({ posts, questions, answers, softenedQuestionIds, materialIndex, matchIndex, }: { posts: Post[] questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] softenedQuestionIds: Set materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): Map => { const questionById = new Map (questions.map (question => [question.id, question])) const nextScores = new Map () const dynamicMatchIndex = new Map> () answers.forEach (answer => { const question = questionById.get (answer.questionId) if (!(question)) return const weight = scoreWeightForAnswer (answer, softenedQuestionIds) applyQuestionAnswerDeltaToScores ({ posts, question, answer: answer.answer, weight, nextScores, materialIndex, matchIndex, dynamicMatchIndex }) }) 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, materialIndex, matchIndex, }: { posts: Post[] scores: Map question: GekanatorQuestion answer: GekanatorAnswerValue materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): AnswerPreview => { const postById = new Map (posts.map (post => [post.id, post])) const dynamicMatchIndex = new Map> () const nextPostIds = postIdsForHardAnswer ({ candidateIds: posts.map (post => post.id), question, answer, posts, materialIndex, matchIndex, dynamicMatchIndex }) const nextPosts = nextPostIds .map (postId => postById.get (postId)) .filter ((post): post is Post => post != null) if (nextPosts.length === 0) return { answer, top: null, candidateCount: 0, effectiveCandidates: 0, entropy: 0 } const nextScores = new Map (scores) applyQuestionAnswerDeltaToScores ({ posts, question, answer, weight: 1, nextScores, materialIndex, matchIndex, dynamicMatchIndex }) 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 != null && 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 'title-contains': return condition.text 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': if ( sameConditionValue (question.condition, previous) || isMonthCrossMatch (question.condition, previous)) return sum + 40 return sum case 'probably_no': if ( sameConditionValue (question.condition, previous) || isMonthCrossMatch (question.condition, previous)) return sum + 20 return sum default: return sum } }, 0) } const chooseQuestion = ( { posts, questions, scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex }: { posts: Post[] questions: GekanatorQuestion[] scores: Map answers: GekanatorAnswerLog[] askedIds: Set gameSeed: string recentFirstQuestionPenaltyById: Map userPriorWeights: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): QuestionSelection | null => { const dynamicMatchIndex = new Map> () const invertedSignature = (signature: string): string => signature.replace (/[01]/g, value => value === '1' ? '0' : '1') const redundantSignatures = (candidates: Post[]): Set => { const signatures = new Set () questions .filter (question => askedIds.has (question.id)) .forEach (question => { const signature = signatureForCandidateIds ({ candidateIds: candidates.map (post => post.id), posts, materialIndex, matchIndex, question, dynamicMatchIndex }) signatures.add (signature) signatures.add (invertedSignature (signature)) }) return signatures } 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 weightedEntropy = distributionEntropy ( normalisedWeightedPosts.map (item => item.weight)) const learningTargetPosts = learningTargetPostsForCandidates ({ scoredPosts, gameSeed }) const rank = ( questionsToRank: GekanatorQuestion[], candidates: { post: Post; score: number }[], weightedCandidates: { post: Post; score: number; weight: number }[]) => { const redundant = redundantSignatures (candidates.map (item => item.post)) const candidateById = new Map (candidates.map (item => [item.post.id, item.post])) const candidateIds = candidates.map (item => item.post.id) const candidateIdSet = new Set (candidateIds) const priorEntries = [...userPriorWeights.entries ()] .filter (([postId]) => candidateById.has (postId)) const priorWeightTotal = priorEntries.reduce ((sum, [, weight]) => sum + weight, 0) 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 = signatureForCandidateIds ({ candidateIds, posts, materialIndex, matchIndex, question, dynamicMatchIndex }) if (redundant.has (signature)) return null if (isUserSuggestedLearnedSemanticQuestion (question)) { const stats = learnedSemanticStatsForCandidateIds ({ candidateIds, posts, question }) const effective = learnedSemanticQuestionIsEffectiveForCandidateIds ({ candidateIds, posts, question }) const learningValue = learnedSemanticLearningValueForTopPosts ({ question, learningTargetPosts, candidateIds, posts }) if (!(effective) && !(learningValue.hasLearningValue)) return null const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) const humanOffset = humanPriorityOffsetFor (question) const sourceBonus = sourcePriorityOffset (question) const priorityBonus = priorityWeightOffset (question) const repeatPenalty = (() => { if (answers.length === 0) return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 return 0 }) () const categoryPenalty = questionCategoryPenalty ( question, answers.length, repeatPenalty) if (!(effective)) { return { question, score: ( -(learningValue.missingTopCount * 20) + learningValue.knownCount * .5 + contradictionPenalty + humanOffset + sourceBonus + priorityBonus + categoryPenalty), narrow: false, effectiveUserSuggested: false, learningUserSuggested: true } } const positiveWeight = weightedCandidates.reduce ((sum, item) => sum + ( stats.positiveIds.has (item.post.id) ? item.weight : 0), 0) const negativeWeight = weightedCandidates.reduce ((sum, item) => sum + ( stats.negativeIds.has (item.post.id) ? item.weight : 0), 0) const unknownWeight = Math.max (0, 1 - positiveWeight - negativeWeight) if (positiveWeight <= 0 || negativeWeight <= 0) return null const positivePosteriorWeights = weightedCandidates .filter (item => stats.positiveIds.has (item.post.id)) .map (item => item.weight / positiveWeight) const negativePosteriorWeights = weightedCandidates .filter (item => stats.negativeIds.has (item.post.id)) .map (item => item.weight / negativeWeight) const unknownPosteriorWeights = unknownWeight > 0 ? weightedCandidates .filter (item => stats.unknownIds.has (item.post.id)) .map (item => item.weight / unknownWeight) : [] const infoGain = weightedEntropy - ( positiveWeight * distributionEntropy (positivePosteriorWeights) + negativeWeight * distributionEntropy (negativePosteriorWeights) + unknownWeight * distributionEntropy (unknownPosteriorWeights)) if (infoGain < (candidates.length >= 10 ? .02 : .008)) return null const weightedSplitScore = Math.abs (.5 - positiveWeight) const unweightedSplitScore = Math.abs (candidates.length / 2 - stats.positiveCount) / candidates.length const narrowPenalty = learnedSemanticNarrowPenaltyForStats (candidates.length, stats) const infoGainBonus = -Math.min (1.2, infoGain) * 4 return { question, score: weightedSplitScore * 100 + unweightedSplitScore * 8 + narrowPenalty + contradictionPenalty + humanOffset + sourceBonus + priorityBonus + categoryPenalty + infoGainBonus, narrow: narrowPenalty > 0, effectiveUserSuggested: true, learningUserSuggested: false } } const yes = matchingPostCountInIds ({ candidateIds, candidateIdSet, posts, materialIndex, matchIndex, question, dynamicMatchIndex }) const no = candidates.length - yes if (yes === 0 || no === 0) return null const yesWeight = matchingWeightInCandidates ({ candidates: weightedCandidates.map (item => ({ post: item.post, weight: item.weight })), posts, materialIndex, matchIndex, question, dynamicMatchIndex }) const noWeight = 1 - yesWeight if (yesWeight <= 0 || noWeight <= 0) return null if (Math.min (yesWeight, noWeight) < .08) return null const matched = positiveMatchingPostIdsForQuestion ({ posts, materialIndex, matchIndex, question, dynamicMatchIndex }) const yesPosteriorWeights = weightedCandidates .filter (item => matched.has (item.post.id)) .map (item => item.weight / yesWeight) const noPosteriorWeights = weightedCandidates .filter (item => !(matched.has (item.post.id))) .map (item => item.weight / noWeight) const infoGain = weightedEntropy - ( yesWeight * distributionEntropy (yesPosteriorWeights) + noWeight * distributionEntropy (noPosteriorWeights)) if (infoGain < (candidates.length >= 10 ? .02 : .008)) 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 humanOffset = humanPriorityOffsetFor (question) const sourceBonus = sourcePriorityOffset (question) const priorityBonus = priorityWeightOffset (question) const repeatPenalty = (() => { if (answers.length === 0) return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 return 0 }) () const categoryPenalty = questionCategoryPenalty ( question, answers.length, repeatPenalty) const priorSplitScore = (() => { if (priorWeightTotal <= 0) return null return Math.abs ( .5 - ( priorEntries.reduce ( (sum, [postId, weight]) => { return sum + (matched.has (postId) ? weight : 0) }, 0) / priorWeightTotal)) }) () const priorBonus = (() => { if (priorSplitScore == null) return 0 return Math.max (0, .22 - priorSplitScore) * -18 }) () const infoGainBonus = -Math.min (1.2, infoGain) * 4 return { question, score: weightedSplitScore * 100 + unweightedSplitScore * 8 + tagPenalty + narrowPenalty + contradictionPenalty + humanOffset + sourceBonus + priorityBonus + categoryPenalty + priorBonus + infoGainBonus, narrow: narrowPenalty > 0, effectiveUserSuggested: false, learningUserSuggested: false } }) .filter ((item): item is { question: GekanatorQuestion score: number narrow: boolean effectiveUserSuggested: boolean learningUserSuggested: 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 generalRankedPool = ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked const effectiveUserSuggestedPool = ranked .filter (item => item.effectiveUserSuggested) .slice (0, 16) const learningUserSuggestedPool = ranked .filter (item => item.learningUserSuggested) .slice (0, 16) const normalPool = generalRankedPool .filter (item => !(item.effectiveUserSuggested) && !(item.learningUserSuggested)) .slice (0, 16) const purposeCounts = questionPurposeCountsFor (answers) const totalNormalPhaseQuestionCount = purposeCounts.totalNormalPhaseQuestionCount const effectiveRatio = totalNormalPhaseQuestionCount > 0 ? purposeCounts.effectiveUserSuggestedCount / totalNormalPhaseQuestionCount : 0 const learningRatio = totalNormalPhaseQuestionCount > 0 ? purposeCounts.learningUserSuggestedCount / totalNormalPhaseQuestionCount : 0 const totalUserSuggestedRatio = totalNormalPhaseQuestionCount > 0 ? purposeCounts.totalUserSuggestedCount / totalNormalPhaseQuestionCount : 0 let selectedPool = normalPool let selectedPurpose: GekanatorQuestionPurpose = 'normal' if ( effectiveRatio < targetEffectiveUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && effectiveUserSuggestedPool.length > 0) { selectedPool = effectiveUserSuggestedPool selectedPurpose = 'effective_user_suggested' } else if ( learningRatio < targetLearningUserSuggestedQuestionRatio && totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio && learningUserSuggestedPool.length > 0) { selectedPool = learningUserSuggestedPool selectedPurpose = 'learning_user_suggested' } else if (normalPool.length > 0) { selectedPool = normalPool selectedPurpose = 'normal' } else if (effectiveUserSuggestedPool.length > 0) { selectedPool = effectiveUserSuggestedPool selectedPurpose = 'effective_user_suggested' } else if (learningUserSuggestedPool.length > 0) { selectedPool = learningUserSuggestedPool selectedPurpose = 'learning_user_suggested' } if (selectedPool.length === 0) return null const bestScore = selectedPool[0]?.score ?? 0 const weightedPool = selectedPool.map (item => ({ ...item, weight: Math.exp ((bestScore - item.score) / (answers.length === 0 ? 2.8 : 2.1)) })) 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 { question: item.question, questionPurpose: selectedPurpose, effectiveQuestion: selectedPurpose === 'effective_user_suggested', learningQuestion: selectedPurpose === 'learning_user_suggested' } } const selectedQuestion = weightedPool[weightedPool.length - 1]?.question if (selectedQuestion == null) return null return { question: selectedQuestion, questionPurpose: selectedPurpose, effectiveQuestion: selectedPurpose === 'effective_user_suggested', learningQuestion: selectedPurpose === 'learning_user_suggested' } } const winningRunPriorityFor = ( expected: GekanatorAnswerValue): number | null => { if (expected === 'yes') return 0 if (expected === 'partial') return 1 if (expected === 'no' || expected === 'probably_no') return 2 return null } const chooseWinningRunQuestion = ({ posts, targetPost, answers, askedIds, acceptedQuestions, materialIndex, matchIndex, }: { posts: Post[] targetPost: Post answers: GekanatorAnswerLog[] askedIds: Set acceptedQuestions: GekanatorQuestion[] materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): GekanatorQuestion | null => { const dynamicMatchIndex = new Map> () const ranked = buildQuestionsForCandidateIds ({ candidateIds: posts.map (post => post.id), materialIndex, acceptedQuestions, mode: 'confirmation', confirmationPostId: targetPost.id}) .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 (expected) if (priority == null) return null let matchingCount = 0 if (isUserSuggestedLearnedSemanticQuestion (question)) { const stats = learnedSemanticStatsForCandidateIds ({ candidateIds: posts.map (post => post.id), posts, question }) matchingCount = expected === 'yes' || expected === 'partial' ? stats.positiveCount : stats.negativeCount } else { const yesCount = matchingPostCountInIds ({ candidateIds: posts.map (post => post.id), posts, materialIndex, matchIndex, question, dynamicMatchIndex }) matchingCount = expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount } return { question, priority, humanOffset: humanPriorityOffsetFor (question), matchingCount } }) .filter ((item): item is { question: GekanatorQuestion priority: number humanOffset: number matchingCount: number } => item != null) .sort ((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority if (a.humanOffset !== b.humanOffset) return a.humanOffset - b.humanOffset if (a.question.priorityWeight !== b.question.priorityWeight) return b.question.priorityWeight - a.question.priorityWeight if (a.matchingCount !== b.matchingCount) return a.matchingCount - b.matchingCount return a.question.id.localeCompare (b.question.id) }) if (ranked.length > 0) return ranked[0]?.question ?? null return null } const chooseFallbackQuestion = ({ posts, allPosts, questions, answers, askedIds, scores, materialIndex, matchIndex, }: { posts: Post[] allPosts: Post[] questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] askedIds: Set scores: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex }): GekanatorQuestion | null => { if (posts.length === 0) return null const candidateIds = posts.map (post => post.id) const fallbackPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) .slice (0, Math.min (6, posts.length)) .map (item => item.post) const fallbackQuestions = mergeQuestions ( fallbackPosts.flatMap (post => buildQuestionsForCandidateIds ({ candidateIds, materialIndex, acceptedQuestions: [], mode: 'confirmation', confirmationPostId: post.id}))) .slice (0, 32) const dynamicMatchIndex = new Map> () const ranked = mergeQuestions ([ ...questions, ...fallbackQuestions]) .filter (question => question.source !== 'user_suggested' && !(askedIds.has (question.id)) && !(isQuestionHardFilteredAfterAnswers (question, answers))) .map (question => { if (isUserSuggestedLearnedSemanticQuestion (question)) { if (!(learnedSemanticQuestionIsEffectiveForCandidateIds ({ candidateIds, posts: allPosts, question }))) return null const stats = learnedSemanticStatsForCandidateIds ({ candidateIds, posts: allPosts, question }) return { question, knownCount: stats.knownCount, balance: Math.abs (stats.positiveCount - stats.negativeCount), humanOffset: humanPriorityOffsetFor (question) } } const yesCount = matchingPostCountInIds ({ candidateIds, posts: allPosts, materialIndex, matchIndex, question, dynamicMatchIndex }) const noCount = candidateIds.length - yesCount if (yesCount === 0 || noCount === 0) return null return { question, knownCount: candidateIds.length, balance: Math.abs (yesCount - noCount), humanOffset: humanPriorityOffsetFor (question) } }) .filter ((item): item is { question: GekanatorQuestion knownCount: number balance: number humanOffset: number } => item != null) .sort ((a, b) => { if (a.humanOffset !== b.humanOffset) return a.humanOffset - b.humanOffset if (a.balance !== b.balance) return a.balance - b.balance if (a.knownCount !== b.knownCount) return b.knownCount - a.knownCount if (a.question.priorityWeight !== b.question.priorityWeight) return b.question.priorityWeight - a.question.priorityWeight return a.question.id.localeCompare (b.question.id) }) return ranked[0]?.question ?? null } const shouldEnterGuessPhase = ( reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' => (reason === 'hard_max_questions' || reason === 'winning_run_finished' || reason === 'question_count_checkpoint') const isWinningRunActive = ( winningRunTargetId: number | null, winningRunStartAnswerCount: number | null): boolean => winningRunTargetId != null && winningRunStartAnswerCount != null const winningRunQuestionCount = ( answers: GekanatorAnswerLog[], winningRunStartAnswerCount: number | null): number => { if (winningRunStartAnswerCount == null) return 0 return answers .slice (winningRunStartAnswerCount) .filter (answer => answer.questionMode === 'winning_run') .length } const nextQuestionPlanFor = ( { posts, eligiblePosts, availablePosts, acceptedQuestions, scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount }: { posts: Post[] eligiblePosts: Post[] availablePosts: Post[] acceptedQuestions: GekanatorQuestion[] scores: Map answers: GekanatorAnswerLog[] askedIds: Set gameSeed: string recentFirstQuestionPenaltyById: Map userPriorWeights: Map materialIndex: GekanatorQuestionMaterialIndex matchIndex: GekanatorMatchIndex lastGuessQuestionCount: number winningRunTargetId: number | null winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null guess: Post | null guessReason: GuessReason | null questionMode: QuestionMode questionPurpose?: GekanatorQuestionPurpose effectiveQuestion?: boolean learningQuestion?: boolean winningRunTargetId: number | null winningRunStartAnswerCount: number | null } => { const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts const checkpointGuess = answers.length > 0 && answers.length - lastGuessQuestionCount >= minQuestionsBeforeCertainGuess if (answers.length >= hardMaxQuestions) { return { question: null, guess: bestPost (guessablePosts, scores), guessReason: 'hard_max_questions', questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId, winningRunStartAnswerCount } } if (checkpointGuess) { return { question: null, guess: bestPost (guessablePosts, scores), guessReason: 'question_count_checkpoint', questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId, winningRunStartAnswerCount } } const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null const nextWinningRunStartAnswerCount = (() => { if (nextWinningRunTargetId == null) return null if ( isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) && winningRunTargetId === nextWinningRunTargetId && winningRunStartAnswerCount != null) return winningRunStartAnswerCount return answers.length }) () const nextWinningRunTargetPost = (() => { if (nextWinningRunTargetId == null) return null return posts.find (post => post.id === nextWinningRunTargetId) ?? null }) () const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] => buildQuestionsForCandidateIds ({ candidateIds: scopePosts.map (post => post.id), materialIndex, acceptedQuestions, mode: 'split' }) if (eligiblePosts.length === 1) { const winningRunFinished = nextWinningRunTargetId != null && nextWinningRunStartAnswerCount != null && eligiblePosts[0]?.id === nextWinningRunTargetId && winningRunQuestionCount ( answers, nextWinningRunStartAnswerCount) >= winningRunQuestionLimit if (winningRunFinished) return { question: null, guess: bestPost (eligiblePosts, scores), guessReason: 'winning_run_finished', questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount == null) return { question: null, guess: null, guessReason: null, questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } const winningRunQuestion = chooseWinningRunQuestion ({ posts, targetPost: nextWinningRunTargetPost, answers, askedIds, acceptedQuestions, materialIndex, matchIndex }) if (winningRunQuestion) return { question: winningRunQuestion, guess: null, guessReason: null, questionMode: 'winning_run', questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } return { question: null, guess: null, guessReason: null, questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } const evaluationPosts = eligiblePosts const evaluationQuestions = buildQuestionsForPosts (evaluationPosts) const normalQuestionSelection = chooseQuestion ({ posts: evaluationPosts, questions: evaluationQuestions, scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex }) const fallbackQuestion = normalQuestionSelection?.question ?? chooseFallbackQuestion ({ posts: evaluationPosts, allPosts: posts, questions: evaluationQuestions, answers, askedIds, scores, materialIndex, matchIndex }) if (fallbackQuestion) { return { question: fallbackQuestion, guess: null, guessReason: null, questionMode: 'normal', questionPurpose: normalQuestionSelection?.question?.id === fallbackQuestion.id ? normalQuestionSelection.questionPurpose : 'normal', effectiveQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id ? normalQuestionSelection.effectiveQuestion : false, learningQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id ? normalQuestionSelection.learningQuestion : false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } return { question: null, guess: null, guessReason: null, questionMode: null, questionPurpose: undefined, effectiveQuestion: false, learningQuestion: false, winningRunTargetId: nextWinningRunTargetId, winningRunStartAnswerCount: nextWinningRunStartAnswerCount } } 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 backgroundThumbnailUrl = (post: Post): string | undefined => post.thumbnail || post.thumbnailBase || undefined const mascotStateFor = ( phase: Phase, resultWon: boolean | null, eligiblePostCount: number, bestConfidencePercent: number, winningRunActive: boolean): MascotState => { const resultPhase = phase === 'end' || phase === 'review' || phase === 'learned' if (resultPhase && !(resultWon)) return 'failed' if (resultPhase && resultWon) return 'celebrate' switch (phase) { case 'question': case 'continue': case 'extra_questions': case 'question_suggestion': if ( winningRunActive || eligiblePostCount <= 2 || bestConfidencePercent >= 70) return 'thinking_near' if ( eligiblePostCount >= 15 && bestConfidencePercent < 45) return 'thinking_far' return 'thinking_mid' case 'guess': case 'end': case 'review': case 'learned': return 'confident' default: return 'idle' } } const backgroundPostsFor = ({ phase, eligiblePosts, availablePosts, displayedGuess, reviewCorrectPost, reviewGuessedPost, }: { phase: Phase eligiblePosts: Post[] availablePosts: Post[] displayedGuess: Post | null reviewCorrectPost: Post | null reviewGuessedPost: Post | null }): Post[] => { const focusPosts = phase === 'end' || phase === 'review' || phase === 'learned' ? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null) : phase === 'guess' ? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null) : eligiblePosts.length > 0 ? eligiblePosts : availablePosts return [...new Map (focusPosts.map (post => [post.id, post])).values ()] } const GekanatorBackdrop: FC<{ posts: Post[] mascotAsset: string phase: Phase displayedGuess?: Post | null visualSeed: string motionMode: BackgroundMotionMode winningRunTargetPost?: Post | null winningRunQuestionCount?: number }> = ({ posts, mascotAsset, phase, displayedGuess = null, visualSeed, motionMode, winningRunTargetPost = null, winningRunQuestionCount = 0 }) => { const guessFocusOffset = useMemo (() => { const focusTiles = [ { x: 'calc(max(100vw, 100vh) * 0.5)', y: 'calc(max(100vw, 100vh) * 0.5)' }, { x: 'calc(max(100vw, 100vh) * -0.5)', y: 'calc(max(100vw, 100vh) * 0.5)' }, { x: 'calc(max(100vw, 100vh) * 0.5)', y: 'calc(max(100vw, 100vh) * -0.5)' }, { x: 'calc(max(100vw, 100vh) * -0.5)', y: 'calc(max(100vw, 100vh) * -0.5)' }] return (focusTiles[Math.abs (hashString (`${ visualSeed }:guess-focus`)) % focusTiles.length] ?? focusTiles[0]) }, [visualSeed]) const directions = useMemo ( () => [ { x: 0, y: -33.333333 }, { x: 33.333333, y: -33.333333 }, { x: 33.333333, y: 0 }, { x: 33.333333, y: 33.333333 }, { x: 0, y: 33.333333 }, { x: -33.333333, y: 33.333333 }, { x: -33.333333, y: 0 }, { x: -33.333333, y: -33.333333 }], []) const guessThumbnail = phase === 'guess' && displayedGuess ? backgroundThumbnailUrl (displayedGuess) : null const isWinningRunBackdrop = !(guessThumbnail) && phase === 'question' && winningRunTargetPost != null && Boolean (backgroundThumbnailUrl (winningRunTargetPost)) const backdropMode = (() => { if (guessThumbnail) return 'guess' if (isWinningRunBackdrop) return 'winning_run' return 'normal' }) () const normalVisiblePosts = useMemo ( () => posts .filter (post => Boolean (backgroundThumbnailUrl (post))) .sort ((left, right) => hashString (`${ visualSeed }:${ left.id }`) - hashString (`${ visualSeed }:${ right.id }`)) .slice (0, motionMode === 'calm' ? 24 : 36), [posts, visualSeed, motionMode]) const settingsForMode = useCallback ( ( mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => { if (mode === 'winning_run' || mode === 'guess') return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 } if (motionMode === 'calm') return { columns: 7, rows: 7, opacity: .14 } return { columns: 10, rows: 10, opacity: .2 } }, [motionMode]) const scaleForMode = useCallback ( ( mode: 'normal' | 'winning_run' | 'guess', displayedWinningCount: number): number => { if (mode === 'guess') return 8 if (mode === 'winning_run') return [1, 8 / 6, 8 / 4, 8 / 2][Math.max (0, Math.min (3, displayedWinningCount))] ?? 1 return 1 }, []) const postsForMode = useCallback (( mode: 'normal' | 'winning_run' | 'guess'): Post[] => { if (mode === 'guess' && displayedGuess) return [displayedGuess] if (mode === 'winning_run' && winningRunTargetPost) return [winningRunTargetPost] return normalVisiblePosts }, [displayedGuess, winningRunTargetPost, normalVisiblePosts]) const thumbnailsForMode = useCallback (( mode: 'normal' | 'winning_run' | 'guess', count: number): string[] => { const modePosts = postsForMode (mode) if (modePosts.length === 0) return [] return Array.from ({ length: count }, (_, index) => { const post = modePosts[index % modePosts.length] return backgroundThumbnailUrl (post) ?? null }).filter ((thumbnail): thumbnail is string => Boolean (thumbnail)) }, [postsForMode]) const targetSettings = settingsForMode (backdropMode) const targetTileCount = targetSettings.columns * targetSettings.rows const nextThumbnails = useMemo ( () => thumbnailsForMode (backdropMode, targetTileCount), [backdropMode, targetTileCount, thumbnailsForMode]) const nextDirection = useMemo ( () => directions[ Math.abs (hashString (`${ visualSeed }:direction`)) % directions.length] ?? directions[0], [visualSeed, directions]) const marqueeDuration = (() => { if (backdropMode === 'winning_run') return motionMode === 'calm' ? 28 : 20 return motionMode === 'calm' ? 34 : 24 }) () const tileFlipDuration = motionMode === 'calm' ? .6 : .45 const x = useMotionValue (0) const y = useMotionValue (0) const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)` const [activeDirection, setActiveDirection] = useState (nextDirection) const activeDirectionRef = useRef (activeDirection) const guessAnimationControlsRef = useRef[]> ([]) const flipTimerRef = useRef (null) const [displayedBackdropMode, setDisplayedBackdropMode] = useState<'normal' | 'winning_run' | 'guess'> (backdropMode) const [displayedWinningRunCount, setDisplayedWinningRunCount] = useState (winningRunQuestionCount) const [displayedThumbnails, setDisplayedThumbnails] = useState ( nextThumbnails) const [fromThumbnails, setFromThumbnails] = useState ( nextThumbnails) const [toThumbnails, setToThumbnails] = useState ( nextThumbnails) const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) const [isFlippingTiles, setIsFlippingTiles] = useState (false) const renderedSettings = settingsForMode (displayedBackdropMode) const renderedTileCount = renderedSettings.columns * renderedSettings.rows const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) const isGuessPresentation = backdropMode === 'guess' || displayedBackdropMode === 'guess' useEffect (() => { guessAnimationControlsRef.current.forEach (control => control.stop ()) guessAnimationControlsRef.current = [] if (motionMode === 'off') return if (!(isGuessPresentation)) return const duration = motionMode === 'calm' ? .95 : .75 const ease = [0.16, 1, 0.3, 1] as const const controls = [ animate (x, 0, { duration, ease }), animate (y, 0, { duration, ease })] guessAnimationControlsRef.current = controls return () => { controls.forEach (control => control.stop ()) guessAnimationControlsRef.current = [] } }, [isGuessPresentation, motionMode, visualSeed, x, y]) useEffect (() => { activeDirectionRef.current = activeDirection }, [activeDirection]) useEffect (() => { const wrap = (value: number): number => { const cell = 33.333333 const wrapped = ((value % cell) + cell) % cell return wrapped > cell / 2 ? wrapped - cell : wrapped } if (motionMode === 'off' || nextThumbnails.length === 0) { guessAnimationControlsRef.current.forEach (control => control.stop ()) guessAnimationControlsRef.current = [] x.set (0) y.set (0) return } if (isGuessPresentation) { guessAnimationControlsRef.current.forEach (control => control.stop ()) guessAnimationControlsRef.current = [] x.set (0) y.set (0) return } const speed = 33.333333 / marqueeDuration let animationFrame: number let previousTime = performance.now () const tick = (time: number) => { const elapsedSeconds = (time - previousTime) / 1000 previousTime = time const direction = activeDirectionRef.current x.set (wrap (x.get () + Math.sign (direction.x) * speed * elapsedSeconds)) y.set (wrap (y.get () + Math.sign (direction.y) * speed * elapsedSeconds)) animationFrame = window.requestAnimationFrame (tick) } animationFrame = window.requestAnimationFrame (tick) return () => window.cancelAnimationFrame (animationFrame) }, [x, y, marqueeDuration, motionMode, isGuessPresentation, nextThumbnails.length]) useEffect (() => { const applyDirection = () => { activeDirectionRef.current = nextDirection setActiveDirection (nextDirection) } if (flipTimerRef.current != null) { window.clearTimeout (flipTimerRef.current) flipTimerRef.current = null } if (motionMode === 'off') { applyDirection () setIsFlippingTiles (false) setFlipVisualSeed (visualSeed) return } if (backdropMode === 'guess' && guessThumbnail) { setIsFlippingTiles (false) setDisplayedBackdropMode ('guess') setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedThumbnails (nextThumbnails) setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails) setFlipVisualSeed (visualSeed) return } if (displayedBackdropMode === 'winning_run' && backdropMode === 'winning_run') { applyDirection () setDisplayedBackdropMode ('winning_run') setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedThumbnails (nextThumbnails) setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails) setIsFlippingTiles (false) setFlipVisualSeed (visualSeed) return } if (nextThumbnails.length === 0) { applyDirection () setIsFlippingTiles (false) setFlipVisualSeed (visualSeed) return } const sameTiles = displayedThumbnails.length === nextThumbnails.length && displayedThumbnails.every ((thumbnail, index) => thumbnail === nextThumbnails[index]) if (sameTiles && flipVisualSeed === visualSeed) { if (activeDirection.x !== nextDirection.x || activeDirection.y !== nextDirection.y) applyDirection () return } const currentThumbnails = displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails setFromThumbnails (currentThumbnails) setToThumbnails (nextThumbnails) setIsFlippingTiles (true) flipTimerRef.current = window.setTimeout (() => { setDisplayedBackdropMode (backdropMode) setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedThumbnails (nextThumbnails) setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails) setIsFlippingTiles (false) applyDirection () setFlipVisualSeed (visualSeed) flipTimerRef.current = null }, tileFlipDuration * 1000) return () => { if (flipTimerRef.current != null) { window.clearTimeout (flipTimerRef.current) flipTimerRef.current = null } } }, [motionMode, backdropMode, displayedBackdropMode, guessThumbnail, nextThumbnails, nextDirection, displayedThumbnails, flipVisualSeed, visualSeed, activeDirection, winningRunQuestionCount, tileFlipDuration, x, y]) if (motionMode === 'off' || nextThumbnails.length === 0) return (
) const backdropTransition: Transition = (() => { if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess') return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] } return { duration: .2 } }) () return (
{Array.from ({ length: 9 }, (_, duplicate) => { const column = duplicate % 3 const row = Math.floor (duplicate / 3) return ( {Array.from ({ length: renderedTileCount }, (_, index) => { const currentThumbnail = displayedThumbnails[ index % Math.max (displayedThumbnails.length, 1)] const frontThumbnail = isFlippingTiles ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] : currentThumbnail const backThumbnail = isFlippingTiles ? toThumbnails[index % Math.max (toThumbnails.length, 1)] : currentThumbnail const thumbnail = displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess' ? nextThumbnails[index % Math.max (nextThumbnails.length, 1)] : currentThumbnail if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail)) return null const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail const showStaticTile = displayedBackdropMode !== 'normal' || !(isFlippingTiles) return ( {showStaticTile && ( ) } {!(showStaticTile) && ( )} ) })} ) })}
) } const expectedAnswerFor = ( question: GekanatorQuestion | undefined, correctPost: Post | null): GekanatorAnswerValue | null => expectedAnswerForQuestion (question, correctPost) const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const storedGame = useMemo (loadStoredGame, []) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () const isAdmin = user?.role === 'admin' const canPersistGame = user != null const [recentGames, setRecentGames] = useState ( () => loadRecentGames ()) const [backgroundMotionMode, setBackgroundMotionMode] = useState ( () => loadBackgroundMotionMode ()) const [prefersReducedMotion, setPrefersReducedMotion] = useState (false) const [gameSeed, setGameSeed] = useState ( storedGame?.gameSeed ?? createGameSeed ()) const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore) const [phase, setPhase] = useState ( hasStoredRestore ? 'intro' : 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< Map > (() => recoveredCandidateMapFromStored ( storedGame?.recoveredCandidatePosts ?? [], storedGame?.scores ?? [])) const [recoveryStepCount, setRecoveryStepCount] = useState ( storedGame?.recoveryStepCount ?? 0) const [askedQuestionBank, setAskedQuestionBank] = useState ( () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion)) const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState ( () => { if ((storedGame?.askedQuestionBank?.length ?? 0) > 0) return [] return 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 [guessReason, setGuessReason] = useState ( storedGame?.guessReason ?? 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 [learnedExampleCount, setLearnedExampleCount] = useState ( storedGame?.learnedExampleCount ?? null) const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] = useState ( storedGame?.questionSuggestionEntryMode ?? 'search') const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState ( storedGame?.questionSuggestionSearch ?? '') const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] = useState (storedGame?.questionSuggestionSelectedId ?? 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 }) const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts]) const acceptedQuestionMatchIndex = useMemo ( () => buildGekanatorMatchIndex (posts, acceptedQuestions), [posts, acceptedQuestions]) useEffect (() => { if (posts.length === 0 || storedAskedQuestionBankIds.length === 0 || !(acceptedQuestionsFetched)) return const questionById = new Map ( mergeQuestions ([ ...acceptedQuestions, ...askedQuestionBank]) .map (question => [question.id, question])) setAskedQuestionBank ( storedAskedQuestionBankIds .map (questionId => questionById.get (questionId)) .filter ((question): question is GekanatorQuestion => question != null)) setStoredAskedQuestionBankIds ([]) }, [posts, storedAskedQuestionBankIds, acceptedQuestionsFetched, askedQuestionBank, acceptedQuestions]) useEffect (() => { if (restorePromptVisible) return 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, guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, savedGameId, learnedExampleCount, gameSeed, questionSuggestionEntryMode, questionSuggestionSearch, questionSuggestionSelectedId, 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, guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, savedGameId, learnedExampleCount, gameSeed, questionSuggestionEntryMode, questionSuggestionSearch, questionSuggestionSelectedId, questionSuggestion, questionSuggestionAnswer, questionSuggestionCount, extraQuestions, extraQuestionAnswers, extraQuestionState, restorePromptVisible]) useEffect (() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return const media = window.matchMedia ('(prefers-reduced-motion: reduce)') const sync = () => setPrefersReducedMotion (media.matches) sync () media.addEventListener ('change', sync) return () => media.removeEventListener ('change', sync) }, []) useEffect (() => { try { localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode) } catch { return } }, [backgroundMotionMode]) const askedQuestionById = useMemo ( () => new Map (askedQuestionBank.map (question => [question.id, question])), [askedQuestionBank]) const eligiblePosts = useMemo ( () => candidatePostsForState ({ posts, questionById: askedQuestionById, materialIndex, matchIndex: acceptedQuestionMatchIndex, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores }), [posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores]) const scoringQuestions = useMemo (() => { return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank]) }, [acceptedQuestions, askedQuestionBank]) const scoringQuestionById = useMemo ( () => new Map (scoringQuestions.map (question => [question.id, question])), [scoringQuestions]) const searchableSuggestedQuestions = useMemo ( () => searchedQuestionsFor (acceptedQuestions, questionSuggestionSearch), [acceptedQuestions, questionSuggestionSearch]) const selectedSuggestedQuestion = useMemo ( () => acceptedQuestions.find ( question => question.recordId === questionSuggestionSelectedId) ?? null, [acceptedQuestions, questionSuggestionSelectedId]) const canSubmitQuestionSuggestion = useMemo (() => { if (!(canPersistGame) || reviewCorrectPostId == null) return false if (questionSuggestionEntryMode === 'search') return selectedSuggestedQuestion != null return questionSuggestion.trim () !== '' }, [ canPersistGame, reviewCorrectPostId, questionSuggestionEntryMode, selectedSuggestedQuestion, questionSuggestion]) const recentFirstQuestionPenaltyById = useMemo (() => { const penalties = new Map () recentGames.forEach ((game, index) => { if (!(game.firstQuestionId)) return penalties.set ( game.firstQuestionId, (penalties.get (game.firstQuestionId) ?? 0) + Math.max (.2, 1 - index * .22)) }) return penalties }, [recentGames]) const userPriorWeights = useMemo ( () => userPriorWeightsFor (posts, recentGames), [posts, recentGames]) const availablePosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) const questionPlan = useMemo ( () => nextQuestionPlanFor ({ posts, eligiblePosts, availablePosts, acceptedQuestions, scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount }), [posts, eligiblePosts, availablePosts, acceptedQuestions, scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount]) const winningRunTargetPost = useMemo ( () => { if (questionPlan.winningRunTargetId == null) return null return posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null }, [posts, questionPlan.winningRunTargetId]) const winningRunQuestionsAsked = winningRunQuestionCount ( answers, questionPlan.winningRunStartAnswerCount) const winningRunActive = isWinningRunActive ( questionPlan.winningRunTargetId, questionPlan.winningRunStartAnswerCount) && winningRunQuestionsAsked < winningRunQuestionLimit && eligiblePosts.length === 1 && eligiblePosts[0]?.id === questionPlan.winningRunTargetId && winningRunTargetPost != null 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 = questionPlan.question const answerPreviews = useMemo ( () => { if (!(isAdmin) || !(currentQuestion)) return [] return answerOptions.map (option => previewAnswer ({ posts: eligiblePosts, scores, question: currentQuestion, answer: option.value, materialIndex, matchIndex: acceptedQuestionMatchIndex })) }, [isAdmin, currentQuestion, eligiblePosts, materialIndex, acceptedQuestionMatchIndex, scores]) const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts const guessConfidences = useMemo ( () => confidencesFor (guessablePosts, scores), [guessablePosts, scores]) const bestConfidencePercent = guessConfidences[0]?.percent ?? 0 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 effectiveResultWon = (() => { if (resultWon != null) return resultWon if (reviewGuessedPostId == null || reviewCorrectPostId == null) return null return reviewGuessedPostId === reviewCorrectPostId }) () const effectiveBackgroundMotionMode = (() => { if (backgroundMotionMode === 'off') return 'off' if (prefersReducedMotion) return 'calm' return backgroundMotionMode }) () const backgroundPosts = useMemo ( () => backgroundPostsFor ({ phase, eligiblePosts, availablePosts, displayedGuess, reviewCorrectPost, reviewGuessedPost }), [phase, eligiblePosts, availablePosts, displayedGuess, reviewCorrectPost, reviewGuessedPost]) const backgroundVisualSeed = `${ gameSeed }:${ phase }:${ answers.length }:${ activeGuessId ?? '' }:${ questionPlan.question?.id ?? '' }:${ questionPlan.questionMode ?? '' }:${ winningRunQuestionsAsked }:${ rejectedPostIds.size }:${ backgroundPosts.slice (0, 8).map (post => post.id).join ('|') }` const mascot = mascotStateFor (phase, effectiveResultWon, eligiblePosts.length, bestConfidencePercent, winningRunActive) const mascotAsset = mascotAssetByState[mascot] const mascotAlt = mascotAltByState[mascot] const saveMutation = useMutation ({ mutationFn: saveGekanatorGame, onSuccess: (data, variables) => { setRecentGames (storeRecentGameSummary ({ correctPostId: variables.correctPostId, firstQuestionId: variables.answers[0]?.questionId ?? null, savedAt: Date.now () })) setSaved (true) setSavedGameId (data.id) setLearnedExampleCount (data.learnedExampleCount) setResultWon (variables.guessedPostId === variables.correctPostId)}}) const questionSuggestionMutation = useMutation ({ mutationFn: saveGekanatorQuestionSuggestion, onSuccess: async data => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setQuestionSuggestionCount (data.count) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes')}}) const extraQuestionAnswersMutation = useMutation ({ mutationFn: saveGekanatorExtraQuestionAnswers, onSuccess: async () => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setExtraQuestionState ('saved') setPhase ('end')}}) const resetExtraQuestionState = () => { const next = resettableExtraQuestionState () setExtraQuestions (next.extraQuestions.slice (0, 2)) setExtraQuestionAnswers (next.extraQuestionAnswers) setExtraQuestionState (next.extraQuestionState) extraQuestionAnswersMutation.reset () } const reset = () => { clearStoredGame () saveMutation.reset () questionSuggestionMutation.reset () setRestorePromptVisible (false) 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) setGuessReason (null) setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) setSavedGameId (null) setLearnedExampleCount (null) setGameSeed (createGameSeed ()) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (null) setQuestionSuggestion ('') setQuestionSuggestionAnswer ('yes') setQuestionSuggestionCount (0) resetExtraQuestionState () setHistory ([]) } const continueStoredGame = () => { setRestorePromptVisible (false) setPhase (storedGame?.phase ?? 'question') } const recoverQuestionState = useCallback (({ 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 nextAskedQuestionById = new Map (nextAskedQuestionBank.map (question => [question.id, question])) const answerCountAtRecovery = (() => { if (allowPreQuestionRecovery) return nextAnswers.length return Math.max (nextAnswers.length - 1, 0) }) () let recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, materialIndex, matchIndex: acceptedQuestionMatchIndex }) let recoveredEligiblePosts = candidatePostsForState ({ posts, questionById: nextAskedQuestionById, materialIndex, matchIndex: acceptedQuestionMatchIndex, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts, scores: recoveredScores }) let recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, acceptedQuestions, mode: 'split' }) let recoveredScoringQuestions = mergeQuestions ([ ...recoveredQuestions, ...nextAskedQuestionBank]) const refreshRecoveredState = () => { recoveredScores = recalculateScores ({ posts, questions: nextAskedQuestionBank, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, materialIndex, matchIndex: acceptedQuestionMatchIndex }) recoveredEligiblePosts = candidatePostsForState ({ posts, questionById: nextAskedQuestionById, materialIndex, matchIndex: acceptedQuestionMatchIndex, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts, scores: recoveredScores }) recoveredQuestions = buildQuestionsForCandidateIds ({ candidateIds: recoveredEligiblePosts.map (post => post.id), materialIndex, acceptedQuestions, mode: 'split' }) recoveredScoringQuestions = mergeQuestions ([ ...recoveredQuestions, ...nextAskedQuestionBank]) } const needsPreQuestionRecovery = () => { if ( !(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0 || recoveredEligiblePosts.length === 1) return false const nextQuestion = chooseQuestion ({ posts: recoveredEligiblePosts, questions: recoveredScoringQuestions, scores: recoveredScores, answers: nextAnswers, askedIds: nextAskedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex }) const fallbackQuestion = nextQuestion?.question ?? chooseFallbackQuestion ({ posts: recoveredEligiblePosts, allPosts: posts, questions: recoveredScoringQuestions, answers: nextAnswers, askedIds: nextAskedIds, scores: recoveredScores, materialIndex, matchIndex: acceptedQuestionMatchIndex }) return !(fallbackQuestion) || !(hasDiscriminatingHardSplitForQuestion ({ candidateIds: recoveredEligiblePosts.map (post => post.id), question: fallbackQuestion, posts, materialIndex, matchIndex: acceptedQuestionMatchIndex })) } 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 (nextAnswers.length >= hardMaxQuestions) break if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ())) 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 } }, [ posts, gameSeed, materialIndex, acceptedQuestions, acceptedQuestionMatchIndex, recentFirstQuestionPenaltyById, userPriorWeights]) const answer = (value: GekanatorAnswerValue) => { if (!(currentQuestion)) { if (questionPlan.guess && shouldEnterGuessPhase (questionPlan.guessReason)) { setActiveGuessId (questionPlan.guess.id) setLastGuessQuestionCount (answers.length) setGuessReason (questionPlan.guessReason) 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, guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId }]) const nextAnswers = [...answers, { questionId: currentQuestion.id, questionText: currentQuestion.text, questionCondition: currentQuestion.condition, questionMode: questionPlan.questionMode ?? undefined, questionPurpose: questionPlan.questionPurpose, effectiveQuestion: questionPlan.effectiveQuestion, learningQuestion: questionPlan.learningQuestion, 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 nextEligiblePosts = recovered.eligiblePosts let nextPlan = nextQuestionPlanFor ({ posts, eligiblePosts: nextEligiblePosts, availablePosts, acceptedQuestions, scores: recovered.scores, answers: nextAnswers, askedIds: nextAskedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount }) let finalRecovered = recovered if ( !(nextPlan.question) && !(shouldEnterGuessPhase (nextPlan.guessReason)) && recovered.eligiblePosts.length !== 1) { const recoveredForQuestion = recoverQuestionState ({ nextAnswers, nextAskedIds, nextAskedQuestionBank, nextSoftenedQuestionIds: recovered.softenedQuestionIds, nextRejectedPostIds: rejectedPostIds, nextRecoveredCandidatePosts: recovered.recoveredCandidatePosts, nextRecoveryStepCount: recovered.recoveryStepCount, allowPreQuestionRecovery: true }) nextPlan = nextQuestionPlanFor ({ posts, eligiblePosts: recoveredForQuestion.eligiblePosts, availablePosts, acceptedQuestions, scores: recoveredForQuestion.scores, answers: nextAnswers, askedIds: nextAskedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount }) finalRecovered = recoveredForQuestion } setScores (finalRecovered.scores) setAskedIds (nextAskedIds) setSoftenedQuestionIds (finalRecovered.softenedQuestionIds) setRecoveredCandidatePosts (finalRecovered.recoveredCandidatePosts) setRecoveryStepCount (finalRecovered.recoveryStepCount) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) setWinningRunTargetId (nextPlan.winningRunTargetId) setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount) if (nextPlan.question) { setGuessReason (null) setActiveGuessId (null) setPhase ('question') return } setGuessReason (nextPlan.guessReason) if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason)) { setActiveGuessId (nextPlan.guess.id) setLastGuessQuestionCount (nextAnswers.length) setPhase ('guess') } } const finishGame = (correctPostId: number) => { const guessedPostId = (() => { if (phase === 'end' || phase === 'review') return reviewGuessedPostId if (phase === 'continue') return lastRejectedGuessId ?? displayedGuess?.id return displayedGuess?.id ?? lastRejectedGuessId }) () if (!(guessedPostId)) return saveMutation.reset () questionSuggestionMutation.reset () resetExtraQuestionState () setSaved (false) setSavedGameId (null) setLearnedExampleCount (null) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (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) setLearnedExampleCount (null) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (null) setSelectingCorrectPost (false) setSearch ('') setPhase ('review') } const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( !(canPersistGame) || 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 = () => { if (saveMutation.isError) { reset () return } if (!(canPersistGame)) { reset () return } saveReviewedResult (reset) } const saveAndLearn = () => { if (!(canPersistGame)) { setPhase ('end') return } resetExtraQuestionState () saveReviewedResult (() => setPhase ('end')) } const submitQuestionSuggestion = () => { const questionText = questionSuggestion.trim () const selectedQuestion = selectedSuggestedQuestion if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending) return saveReviewedResult (gekanatorGameId => { questionSuggestionMutation.mutate ({ gekanatorGameId, existingQuestionId: selectedQuestion?.recordId, questionText: selectedQuestion ? undefined : questionText, answer: questionSuggestionAnswer }) }) } const saveExtraQuestions = () => { if ( !(canPersistGame) || 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 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) setGuessReason (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) setGuessReason (snapshot.guessReason) 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 nextPlan = nextQuestionPlanFor ({ posts, eligiblePosts: recovered.eligiblePosts, availablePosts, acceptedQuestions, scores: recovered.scores, answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, userPriorWeights, materialIndex, matchIndex: acceptedQuestionMatchIndex, lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount }) setWinningRunTargetId (nextPlan.winningRunTargetId) setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount) if (nextPlan.question) { setGuessReason (null) setActiveGuessId (null) setPhase ('question') return } setGuessReason (nextPlan.guessReason) if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason)) { setActiveGuessId (nextPlan.guess.id) setLastGuessQuestionCount (answers.length) setPhase ('guess') } } const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { setSaved (false) setSavedGameId (null) setLearnedExampleCount (null) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (null) resetExtraQuestionState () setAnswers (answers.map ((answer, i) => i === index ? { ...answer, answer: value } : answer)) } const selectCorrectPost = (post: Post) => { if (phase === 'review') { setSaved (false) setSavedGameId (null) setLearnedExampleCount (null) setQuestionSuggestionEntryMode ('search') setQuestionSuggestionSearch ('') setQuestionSuggestionSelectedId (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.slice (0, 2)) 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 introDialogue = <>私は洗澡鹿シーザオグカ。質問から投稿を何でも当ててみせるよ。 const winDialogue = <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! const loseDialogue = <>ぬわーん! 洗澡鹿シーザオグカ外しちゃったグカー!!!!! const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue const dialogue = phase === 'learned' ? resultDialogue : introDialogue const introLoadingMessage = phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……' const questionSuggestionTitle = questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。' const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = !(introLoading) && acceptedQuestionsFetched && posts.length > 0 && !(error) && !(acceptedQuestionsError) useEffect (() => { if ( phase !== 'question' || currentQuestion || isLoading || acceptedQuestionsLoading || shouldEnterGuessPhase (questionPlan.guessReason) || eligiblePosts.length === 1) return const recovered = recoverQuestionState ({ nextAnswers: answers, nextAskedIds: askedIds, nextAskedQuestionBank: askedQuestionBank, nextSoftenedQuestionIds: softenedQuestionIds, nextRejectedPostIds: rejectedPostIds, nextRecoveredCandidatePosts: recoveredCandidatePosts, nextRecoveryStepCount: recoveryStepCount, allowPreQuestionRecovery: true }) if ( recovered.recoveryStepCount === recoveryStepCount && recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size && recovered.softenedQuestionIds.size === softenedQuestionIds.size) return setSoftenedQuestionIds (recovered.softenedQuestionIds) setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) setRecoveryStepCount (recovered.recoveryStepCount) setScores (recovered.scores) }, [ phase, currentQuestion, questionPlan, answers, askedIds, askedQuestionBank, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, recoveryStepCount, eligiblePosts, recoverQuestionState, isLoading, acceptedQuestionsLoading]) useEffect (() => { if ( phase !== 'question' || isLoading || acceptedQuestionsLoading) return if ( currentQuestion || !(questionPlan.guess) || !(shouldEnterGuessPhase (questionPlan.guessReason))) return setWinningRunTargetId (questionPlan.winningRunTargetId) setWinningRunStartAnswerCount (questionPlan.winningRunStartAnswerCount) setActiveGuessId (questionPlan.guess.id) setLastGuessQuestionCount (answers.length) setGuessReason (questionPlan.guessReason) setPhase ('guess') }, [ phase, currentQuestion, questionPlan, answers, isLoading, acceptedQuestionsLoading]) return ( {`グカネータ | ${ SITE_TITLE }`}

グカネータ

背景 {[{ mode: 'off' as const, label: 'オフ' }, { mode: 'on' as const, label: 'オン' }] .map (({ mode, label }) => { const modeClass = backgroundMotionMode === mode ? 'bg-pink-600 text-white' : 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900' return ( ) })} {prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && ( 端末設定により控えめ表示 )}
{mascotAlt}
{phase === 'intro' && (

{dialogue}

)} {introLoading && (

{introLoadingMessage}

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

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

} {phase === 'intro' && readyToStart && restorePromptVisible && (
)} {phase === 'intro' && readyToStart && !(restorePromptVisible) && ( )} {phase === 'question' && currentQuestion && (

質問 {answers.length + 1}

{currentQuestion.text}

{isAdmin && (
現在候補: {eligiblePosts.length} 件
winningRunTargetId: {String (questionPlan.winningRunTargetId)} {' / '} winningRunQuestionCount: {winningRunQuestionsAsked} {' / '} guessReason: {guessReason ?? '-'} {' / '} questionMode: {questionPlan.questionMode ?? '-'} {' / '} recoveryStepCount: {recoveryStepCount} {' / '} currentQuestion===null: {String (currentQuestion == null)}
{topScoredPosts.length > 0 && (
{topScoredPosts.map (item => ( #{item.post.id}: score {item.score.toFixed (1)} ))}
)}
)} {isAdmin && answerPreviews.length > 0 && (
{answerOptions.map (option => { const preview = answerPreviews.find (item => item.answer === option.value) return (
{option.label} {' '} 候補 {preview ? preview.candidateCount : 0} 件
effective {preview?.effectiveCandidates.toFixed (2) ?? '0.00'} {' / '} entropy {preview?.entropy.toFixed (2) ?? '0.00'}
) })}
)}
{answerOptions.map (option => ( ))} {history.length > 0 && ( )}
)} {phase === 'question' && !(currentQuestion) && isAdmin && (
question stalled
winningRunTargetId: {String (questionPlan.winningRunTargetId)} {' / '} winningRunQuestionCount: {winningRunQuestionsAsked} {' / '} guessReason: {questionPlan.guessReason ?? '-'} {' / '} questionMode: {questionPlan.questionMode ?? '-'} {' / '} recoveryStepCount: {recoveryStepCount} {' / '} candidateCount: {eligiblePosts.length}
)} {phase === 'guess' && displayedGuess && (

思い浮かべているのは、これだね?

{isAdmin && (
winningRunTargetId: {String (questionPlan.winningRunTargetId)} {' / '} winningRunQuestionCount: {winningRunQuestionsAsked} {' / '} guessReason: {guessReason ?? '-'} {' / '} questionMode: {questionPlan.questionMode ?? '-'} {' / '} recoveryStepCount: {recoveryStepCount} {' / '} currentQuestion===null: {String (currentQuestion == null)}
)}
{history.length > 0 && ( )}
{saveMutation.isError && (

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

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

続けますか?

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

{resultDialogue}

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

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

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

判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : 'はずれ'}

)} {saveMutation.isError && (

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

)} {!(canPersistGame) && (

未ログインのため今回の結果は保存されません。 設定 から引継ぎコードを復元すると、質問追加や追加学習も使へます。

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

結果修正

{reviewGuessedPost && (
推測した投稿
)}
正解の投稿
{reviewCorrectPost && } {!(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' && (

質問追加

{questionSuggestionTitle}

{questionSuggestionEntryMode === 'search' && ( <> {searchableSuggestedQuestions.length > 0 && ( <>
既存質問候補
{searchableSuggestedQuestions.map (question => { const questionClass = questionSuggestionSelectedId === (question.recordId ?? null) ? 'border-pink-600 bg-pink-50 dark:bg-red-900/50' : 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900' return ( ) })}
{selectedSuggestedQuestion && (

既存質問を選択中: {selectedSuggestedQuestion.text}

)} )} )} {questionSuggestionEntryMode === 'new' && (
新規質問