このコミットが含まれているのは:
2026-06-12 01:33:40 +09:00
コミット a5d08c99cf
14個のファイルの変更1076行の追加301行の削除
+160 -253
ファイルの表示
@@ -17,6 +17,12 @@ import { buildGekanatorQuestions,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion,
titleLengthMinimumForCondition } from '@/lib/gekanator'
import { allConcreteAnswerOptionsExhausted,
candidatePostsFor,
hardFilteredPostsForAnswer,
recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -28,6 +34,7 @@ import type { GekanatorAnswerLog,
GekanatorQuestionCondition,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery'
import type { Post } from '@/types'
type Phase =
@@ -63,6 +70,8 @@ type GameSnapshot = {
answers: GekanatorAnswerLog[]
askedIds: Set<string>
softenedQuestionIds: Set<string>
recoveredCandidatePosts: Map<number, number>
recoveryStepCount: number
askedQuestionBank: GekanatorQuestion[]
search: string
selectingCorrectPost: boolean
@@ -79,6 +88,8 @@ type StoredGekanatorGame = {
answers: GekanatorAnswerLog[]
askedIds: string[]
softenedQuestionIds: string[]
recoveredCandidatePosts?: RecoveredCandidatePost[]
recoveryStepCount?: number
askedQuestionBank?: StoredGekanatorQuestion[]
askedQuestionBankIds?: string[]
search: string
@@ -178,6 +189,8 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
softenedQuestionIds: game.softenedQuestionIds.map (questionId =>
normalizeStoredQuestionId (questionId)),
recoveredCandidatePosts: game.recoveredCandidatePosts ?? [],
recoveryStepCount: game.recoveryStepCount ?? 0,
askedQuestionBank: game.askedQuestionBank?.map (question =>
({
...question,
@@ -280,6 +293,20 @@ const resettableExtraQuestionState = (): {
extraQuestionState: 'idle' })
const recoveredCandidateMapFromStored = (
items: RecoveredCandidatePost[],
): Map<number, number> =>
new Map (items.map (item => [item.postId, item.answerCountAtRecovery]))
const storedRecoveredCandidatesFromMap = (
recoveredCandidatePosts: Map<number, number>,
): RecoveredCandidatePost[] =>
[...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({
postId,
answerCountAtRecovery }))
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
switch (answer)
{
@@ -399,48 +426,6 @@ const recalculateScores = ({
}
const candidatePostsFor = ({
posts,
questions,
answers,
softenedQuestionIds,
rejectedPostIds,
}: {
posts: Post[]
questions: GekanatorQuestion[]
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
}): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
if (rejectedPostIds.has (post.id))
return false
return answers.every (answer => {
if (softenedQuestionIds.has (answer.questionId))
return true
const question = questionById.get (answer.questionId)
if (!(question))
return true
switch (answer.answer)
{
case 'yes':
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
})
})
}
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
if (posts.length === 0)
return []
@@ -489,17 +474,18 @@ const previewAnswer = ({
question: GekanatorQuestion
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'unknown'
? posts
: posts.filter (post => {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer
})
const nextPosts =
hardFilteredPosts.length > 0
? hardFilteredPosts
: posts
const nextPosts = hardFilteredPostsForAnswer ({
posts,
question,
answer })
if (nextPosts.length === 0)
return {
answer,
top: null,
candidateCount: 0,
effectiveCandidates: 0,
entropy: 0 }
const nextScores = new Map (scores)
nextPosts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
@@ -628,164 +614,6 @@ const sameConditionValue = (
}
const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
if (condition.type === 'original-month')
return condition.month
if (condition.type !== 'original-month-day')
return null
const month = Number (condition.monthDay.split ('-')[0])
return Number.isInteger (month) ? month : null
}
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
return false
switch (answer)
{
case 'yes':
return candidateLength <= previousLength
case 'no':
return candidateLength >= previousLength
default:
return false
}
}
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
})
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.host === previous.host
default:
return false
}
}
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
switch (answer)
{
case 'yes':
return true
case 'no':
return candidate.year === previous.year
default:
return false
}
}
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
switch (answer)
{
case 'yes':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return true
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) !== previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month'
|| candidate.type === 'original-month-day'
return false
case 'no':
if (previous.type === 'original-month')
{
if (candidate.type === 'original-month')
return candidate.month === previous.month
if (candidate.type === 'original-month-day')
return monthForCondition (candidate) === previous.month
return false
}
if (previous.type === 'original-month-day')
return candidate.type === 'original-month-day'
&& candidate.monthDay === previous.monthDay
return false
default:
return false
}
}
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
return isSourceFactBlocked (candidate, previous, answer)
|| isOriginalYearFactBlocked (candidate, previous, answer)
|| isOriginalMonthFactBlocked (candidate, previous, answer)
}
const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
return isQuestionRedundantAfterAnswers (question, [answer])
|| isFactQuestionBlocked (question.condition, previous, answer.answer)
})
const isMonthCrossMatch = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
@@ -1046,6 +874,10 @@ const GekanatorPage: FC = () => {
() => new Set (storedGame?.askedIds ?? []))
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
() => new Set (storedGame?.softenedQuestionIds ?? []))
const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState<Map<number, number>> (
() => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? []))
const [recoveryStepCount, setRecoveryStepCount] = useState (
storedGame?.recoveryStepCount ?? 0)
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
@@ -1136,6 +968,8 @@ const GekanatorPage: FC = () => {
answers,
askedIds: [...askedIds],
softenedQuestionIds: [...softenedQuestionIds],
recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts),
recoveryStepCount,
askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
askedQuestionBankIds: storedAskedQuestionBankIds,
search,
@@ -1171,6 +1005,8 @@ const GekanatorPage: FC = () => {
answers,
askedIds,
softenedQuestionIds,
recoveredCandidatePosts,
recoveryStepCount,
askedQuestionBank,
storedAskedQuestionBankIds,
search,
@@ -1198,8 +1034,10 @@ const GekanatorPage: FC = () => {
questions: askedQuestionBank,
answers,
softenedQuestionIds,
rejectedPostIds }),
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
rejectedPostIds,
recoveredCandidatePosts }),
[posts, askedQuestionBank, answers, softenedQuestionIds,
rejectedPostIds, recoveredCandidatePosts])
const questions = useMemo (
() => mergeQuestions ([
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
@@ -1212,14 +1050,14 @@ const GekanatorPage: FC = () => {
() => new Map (scoringQuestions.map (question => [question.id, question])),
[scoringQuestions])
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
const nonRejectedPosts = useMemo (
const availablePosts = useMemo (
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
[posts, rejectedPostIds])
const questionPosts =
eligiblePosts.length > 1
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
? eligiblePosts
: nonRejectedPosts
: availablePosts
const topScoredPosts = useMemo (
() => eligiblePosts
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
@@ -1245,7 +1083,7 @@ const GekanatorPage: FC = () => {
const guessablePosts =
eligiblePosts.length > 0
? eligiblePosts
: nonRejectedPosts
: availablePosts
const guess = bestPost (guessablePosts, scores)
const displayedGuess =
posts.find (post => post.id === activeGuessId) ?? guess
@@ -1293,6 +1131,8 @@ const GekanatorPage: FC = () => {
setAnswers ([])
setAskedIds (new Set ())
setSoftenedQuestionIds (new Set ())
setRecoveredCandidatePosts (new Map ())
setRecoveryStepCount (0)
setAskedQuestionBank ([])
setSearch ('')
setSelectingCorrectPost (false)
@@ -1319,14 +1159,26 @@ const GekanatorPage: FC = () => {
nextAskedQuestionBank,
nextSoftenedQuestionIds,
nextRejectedPostIds,
nextRecoveredCandidatePosts,
nextRecoveryStepCount,
allowPreQuestionRecovery,
}: {
nextAnswers: GekanatorAnswerLog[]
nextAskedIds: Set<string>
nextAskedQuestionBank: GekanatorQuestion[]
nextSoftenedQuestionIds: Set<string>
nextRejectedPostIds: Set<number>
nextRecoveredCandidatePosts: Map<number, number>
nextRecoveryStepCount: number
allowPreQuestionRecovery?: boolean
}) => {
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts)
let recoveredStepCount = nextRecoveryStepCount
const answerCountAtRecovery =
allowPreQuestionRecovery
? nextAnswers.length
: Math.max (nextAnswers.length - 1, 0)
let recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
@@ -1337,27 +1189,72 @@ const GekanatorPage: FC = () => {
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts })
let recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
while (
recoveredEligiblePosts.length === 0
|| (
recoveredEligiblePosts.length !== 1
&& !(chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })))
)
const refreshRecoveredState = () => {
recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts })
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
}
const needsPreQuestionRecovery = () => {
if (!(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0)
return false
const nextQuestion = chooseQuestion ({
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })
return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion)
}
while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ())
{
if (nextAnswers.length >= hardMaxQuestions)
const recoveredPosts = recoverCandidatePosts ({
posts,
scores: recoveredScores,
rejectedPostIds: nextRejectedPostIds,
recoveredCandidatePosts,
eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)),
answerCountAtRecovery,
recoveryStepCount: recoveredStepCount })
if (recoveredPosts)
{
recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts
recoveredStepCount = recoveredPosts.recoveryStepCount
refreshRecoveredState ()
if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
break
}
if (
recoveredEligiblePosts.length > 0
|| nextAnswers.length >= hardMaxQuestions
)
break
const softened = softenNextQuestionIds ({
@@ -1368,26 +1265,13 @@ const GekanatorPage: FC = () => {
break
recoveredSoftenedQuestionIds = softened
recoveredScores = recalculateScores ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds })
recoveredEligiblePosts = candidatePostsFor ({
posts,
questions: nextAskedQuestionBank,
answers: nextAnswers,
softenedQuestionIds: recoveredSoftenedQuestionIds,
rejectedPostIds: nextRejectedPostIds })
recoveredScoringQuestions = mergeQuestions ([
...buildGekanatorQuestions (
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
...acceptedQuestions,
...nextAskedQuestionBank])
refreshRecoveredState ()
}
return {
softenedQuestionIds: recoveredSoftenedQuestionIds,
recoveredCandidatePosts,
recoveryStepCount: recoveredStepCount,
scores: recoveredScores,
eligiblePosts: recoveredEligiblePosts,
scoringQuestions: recoveredScoringQuestions }
@@ -1407,6 +1291,8 @@ const GekanatorPage: FC = () => {
answers: [...answers],
askedIds: new Set (askedIds),
softenedQuestionIds: new Set (softenedQuestionIds),
recoveredCandidatePosts: new Map (recoveredCandidatePosts),
recoveryStepCount,
askedQuestionBank: [...askedQuestionBank],
search,
selectingCorrectPost,
@@ -1431,21 +1317,26 @@ const GekanatorPage: FC = () => {
nextAskedIds,
nextAskedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
nextRejectedPostIds: rejectedPostIds,
nextRecoveredCandidatePosts: recoveredCandidatePosts,
nextRecoveryStepCount: recoveryStepCount })
const nextSoftenedQuestionIds = recovered.softenedQuestionIds
const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts
const nextScores = recovered.scores
const nextEligiblePosts = recovered.eligiblePosts
setScores (nextScores)
setAskedIds (nextAskedIds)
setSoftenedQuestionIds (nextSoftenedQuestionIds)
setRecoveredCandidatePosts (nextRecoveredCandidatePosts)
setRecoveryStepCount (recovered.recoveryStepCount)
setAskedQuestionBank (nextAskedQuestionBank)
setAnswers (nextAnswers)
const nextGuessablePosts =
nextEligiblePosts.length > 0
? nextEligiblePosts
: nonRejectedPosts
: availablePosts
const nextGuess = bestPost (nextGuessablePosts, nextScores)
const nextQuestionCount = answers.length + 1
const nextQuestionsSinceLastGuess =
@@ -1581,6 +1472,10 @@ const GekanatorPage: FC = () => {
}
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
setRecoveredCandidatePosts (
new Map (
[...recoveredCandidatePosts.entries ()].filter (
([postId]) => postId !== displayedGuess.id)))
setActiveGuessId (null)
setSearch ('')
setSelectingCorrectPost (false)
@@ -1598,6 +1493,8 @@ const GekanatorPage: FC = () => {
setAnswers (snapshot.answers)
setAskedIds (snapshot.askedIds)
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts)
setRecoveryStepCount (snapshot.recoveryStepCount)
setAskedQuestionBank (snapshot.askedQuestionBank)
setSearch (snapshot.search)
setSelectingCorrectPost (snapshot.selectingCorrectPost)
@@ -1619,15 +1516,24 @@ const GekanatorPage: FC = () => {
nextAskedIds: askedIds,
nextAskedQuestionBank: askedQuestionBank,
nextSoftenedQuestionIds: softenedQuestionIds,
nextRejectedPostIds: rejectedPostIds })
nextRejectedPostIds: rejectedPostIds,
nextRecoveredCandidatePosts: recoveredCandidatePosts,
nextRecoveryStepCount: recoveryStepCount,
allowPreQuestionRecovery: true })
setSoftenedQuestionIds (recovered.softenedQuestionIds)
setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
setRecoveryStepCount (recovered.recoveryStepCount)
setScores (recovered.scores)
const recoveredGuessablePosts =
recovered.eligiblePosts.length > 0
? recovered.eligiblePosts
: availablePosts
const nextQuestion = chooseQuestion ({
posts: recovered.eligiblePosts.length > 1
? recovered.eligiblePosts
: nonRejectedPosts,
: availablePosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
answers,
@@ -1640,7 +1546,7 @@ const GekanatorPage: FC = () => {
return
}
setActiveGuessId (guess?.id ?? null)
setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.id ?? null)
setPhase ('guess')
}
@@ -1694,12 +1600,13 @@ const GekanatorPage: FC = () => {
setExtraQuestions ([])
setExtraQuestionAnswers ({ })
setPhase ('extra_questions')
const nonce = createGameSeed ()
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId),
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
queryKey: gekanatorKeys.extraQuestions (gameId, nonce),
queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) })
setExtraQuestions (questions)
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
}