|
|
|
@@ -18,7 +18,7 @@ import type { GekanatorAnswerLog,
|
|
|
|
|
GekanatorQuestion } from '@/lib/gekanator'
|
|
|
|
|
import type { Post } from '@/types'
|
|
|
|
|
|
|
|
|
|
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned'
|
|
|
|
|
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned'
|
|
|
|
|
|
|
|
|
|
type AnswerOption = {
|
|
|
|
|
label: string
|
|
|
|
@@ -41,14 +41,16 @@ type GameSnapshot = {
|
|
|
|
|
scores: Map<number, number>
|
|
|
|
|
answers: GekanatorAnswerLog[]
|
|
|
|
|
askedIds: Set<string>
|
|
|
|
|
candidateIds: Set<number> | null
|
|
|
|
|
softenedQuestionIds: Set<string>
|
|
|
|
|
questionBank: GekanatorQuestion[]
|
|
|
|
|
askedQuestionBank: GekanatorQuestion[]
|
|
|
|
|
search: string
|
|
|
|
|
selectingCorrectPost: boolean
|
|
|
|
|
rejectedPostIds: Set<number>
|
|
|
|
|
lastGuessQuestionCount: number
|
|
|
|
|
lastRejectedGuessId: number | null
|
|
|
|
|
activeGuessId: number | null }
|
|
|
|
|
activeGuessId: number | null
|
|
|
|
|
reviewGuessedPostId: number | null
|
|
|
|
|
reviewCorrectPostId: number | null }
|
|
|
|
|
|
|
|
|
|
const answerOptions: AnswerOption[] = [
|
|
|
|
|
{ label: 'はい', value: 'yes' },
|
|
|
|
@@ -58,6 +60,9 @@ const answerOptions: AnswerOption[] = [
|
|
|
|
|
{ label: 'わからない', value: 'unknown' }]
|
|
|
|
|
|
|
|
|
|
const questionsBetweenGuesses = 25
|
|
|
|
|
const minQuestionsBeforeCertainGuess = 5
|
|
|
|
|
const certainGuessPercent = 99.5
|
|
|
|
|
const runnerUpMaxPercent = .5
|
|
|
|
|
const hardMaxQuestions = 80
|
|
|
|
|
const softenedAnswerWeight = .35
|
|
|
|
|
const confidenceTemperature = 6
|
|
|
|
@@ -87,18 +92,14 @@ const answerWeightFor = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const questionDifficulty = (question: GekanatorQuestion): number => {
|
|
|
|
|
if (question.id === 'structure:many-tags')
|
|
|
|
|
return 6
|
|
|
|
|
if (question.id.startsWith ('date:'))
|
|
|
|
|
return 5
|
|
|
|
|
if (question.id === 'title:long' || question.id === 'title:ascii')
|
|
|
|
|
return 4
|
|
|
|
|
if (question.kind === 'source')
|
|
|
|
|
return 4
|
|
|
|
|
if (question.kind === 'original_date')
|
|
|
|
|
return 4
|
|
|
|
|
if (question.kind === 'title')
|
|
|
|
|
return 4
|
|
|
|
|
if (question.kind === 'tag')
|
|
|
|
|
return 3
|
|
|
|
|
if (question.kind === 'title' || question.kind === 'structure')
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
@@ -136,6 +137,47 @@ 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':
|
|
|
|
|
return question.test (post)
|
|
|
|
|
case 'no':
|
|
|
|
|
return !(question.test (post))
|
|
|
|
|
default:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
|
|
|
|
|
if (posts.length === 0)
|
|
|
|
|
return []
|
|
|
|
@@ -306,26 +348,12 @@ const chooseQuestion = ({
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
const splitScore = Math.abs (candidates.length / 2 - yes)
|
|
|
|
|
const answerPreviews = answerOptions.map (option =>
|
|
|
|
|
previewAnswer ({
|
|
|
|
|
posts: candidates.map (({ post }) => post),
|
|
|
|
|
scores,
|
|
|
|
|
question,
|
|
|
|
|
answer: option.value }))
|
|
|
|
|
const expectedEntropy =
|
|
|
|
|
answerPreviews.reduce ((sum, preview) => sum + preview.entropy, 0)
|
|
|
|
|
/ answerPreviews.length
|
|
|
|
|
const expectedCandidateCount =
|
|
|
|
|
answerPreviews.reduce ((sum, preview) => sum + preview.candidateCount, 0)
|
|
|
|
|
/ answerPreviews.length
|
|
|
|
|
const kindPenalty = askedIds.has (question.kind) ? 2 : 0
|
|
|
|
|
const tagPenalty = question.kind === 'tag' ? 0 : 10
|
|
|
|
|
const tagPenalty = question.kind === 'tag' ? 0 : 20
|
|
|
|
|
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
|
|
|
|
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
|
|
|
|
|
|
|
|
|
return { question,
|
|
|
|
|
score: splitScore + expectedEntropy + expectedCandidateCount / 8
|
|
|
|
|
+ kindPenalty + tagPenalty + narrowPenalty,
|
|
|
|
|
score: splitScore + tagPenalty + narrowPenalty,
|
|
|
|
|
narrow: narrowPenalty > 0 }
|
|
|
|
|
})
|
|
|
|
|
.filter ((item): item is {
|
|
|
|
@@ -373,34 +401,47 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
|
|
|
|
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
|
|
|
|
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
|
|
|
|
|
const [candidateIds, setCandidateIds] = useState<Set<number> | null> (null)
|
|
|
|
|
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
|
|
|
|
|
const [questionBank, setQuestionBank] = useState<GekanatorQuestion[]> ([])
|
|
|
|
|
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
|
|
|
|
|
const [search, setSearch] = useState ('')
|
|
|
|
|
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
|
|
|
|
|
const [saved, setSaved] = useState (false)
|
|
|
|
|
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
|
|
|
|
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
|
|
|
|
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
|
|
|
|
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
|
|
|
|
const [activeGuessId, setActiveGuessId] = useState<number | null> (null)
|
|
|
|
|
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (null)
|
|
|
|
|
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (null)
|
|
|
|
|
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
|
|
|
|
|
|
|
|
|
const { data: posts = [], isLoading, error } = useQuery ({
|
|
|
|
|
queryKey: gekanatorKeys.posts (),
|
|
|
|
|
queryFn: fetchGekanatorPosts })
|
|
|
|
|
|
|
|
|
|
const candidatePosts = useMemo (
|
|
|
|
|
() => posts.filter (post => candidateIds === null || candidateIds.has (post.id)),
|
|
|
|
|
[posts, candidateIds])
|
|
|
|
|
const eligiblePosts = useMemo (
|
|
|
|
|
() => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))),
|
|
|
|
|
[candidatePosts, rejectedPostIds])
|
|
|
|
|
() => candidatePostsFor ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: askedQuestionBank,
|
|
|
|
|
answers,
|
|
|
|
|
softenedQuestionIds,
|
|
|
|
|
rejectedPostIds }),
|
|
|
|
|
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
|
|
|
|
const questions = useMemo (
|
|
|
|
|
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
|
|
|
|
[eligiblePosts, posts])
|
|
|
|
|
const scoringQuestions = useMemo (() => {
|
|
|
|
|
return mergeQuestions ([...questions, ...questionBank])
|
|
|
|
|
}, [questions, questionBank])
|
|
|
|
|
return mergeQuestions ([...questions, ...askedQuestionBank])
|
|
|
|
|
}, [questions, askedQuestionBank])
|
|
|
|
|
const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
|
|
|
|
|
const nonRejectedPosts = useMemo (
|
|
|
|
|
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
|
|
|
|
|
[posts, rejectedPostIds])
|
|
|
|
|
const questionPosts =
|
|
|
|
|
eligiblePosts.length > 1
|
|
|
|
|
|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
|
|
|
|
|
? eligiblePosts
|
|
|
|
|
: nonRejectedPosts
|
|
|
|
|
const topScoredPosts = useMemo (
|
|
|
|
|
() => eligiblePosts
|
|
|
|
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
|
|
|
@@ -408,7 +449,7 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
.slice (0, 3),
|
|
|
|
|
[eligiblePosts, scores])
|
|
|
|
|
const currentQuestion = chooseQuestion ({
|
|
|
|
|
posts: eligiblePosts, questions: scoringQuestions, scores, askedIds })
|
|
|
|
|
posts: questionPosts, questions: scoringQuestions, scores, askedIds })
|
|
|
|
|
const answerPreviews = useMemo (
|
|
|
|
|
() => currentQuestion
|
|
|
|
|
? answerOptions.map (option => previewAnswer ({
|
|
|
|
@@ -418,29 +459,122 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
answer: option.value }))
|
|
|
|
|
: [],
|
|
|
|
|
[currentQuestion, eligiblePosts, scores])
|
|
|
|
|
const guess = bestPost (eligiblePosts, scores)
|
|
|
|
|
const guessablePosts =
|
|
|
|
|
eligiblePosts.length > 0
|
|
|
|
|
? eligiblePosts
|
|
|
|
|
: nonRejectedPosts
|
|
|
|
|
const guess = bestPost (guessablePosts, scores)
|
|
|
|
|
const displayedGuess =
|
|
|
|
|
posts.find (post => post.id === activeGuessId) ?? guess
|
|
|
|
|
const saveMutation = useMutation ({ mutationFn: saveGekanatorGame })
|
|
|
|
|
const reviewGuessedPost =
|
|
|
|
|
posts.find (post => post.id === reviewGuessedPostId) ?? null
|
|
|
|
|
const reviewCorrectPost =
|
|
|
|
|
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
|
|
|
|
const saveMutation = useMutation ({
|
|
|
|
|
mutationFn: saveGekanatorGame,
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setSaved (true)
|
|
|
|
|
setResultWon (reviewGuessedPostId === reviewCorrectPostId)
|
|
|
|
|
setPhase ('learned')
|
|
|
|
|
}})
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
saveMutation.reset ()
|
|
|
|
|
setPhase ('intro')
|
|
|
|
|
setScores (new Map ())
|
|
|
|
|
setAnswers ([])
|
|
|
|
|
setAskedIds (new Set ())
|
|
|
|
|
setCandidateIds (null)
|
|
|
|
|
setSoftenedQuestionIds (new Set ())
|
|
|
|
|
setQuestionBank ([])
|
|
|
|
|
setAskedQuestionBank ([])
|
|
|
|
|
setSearch ('')
|
|
|
|
|
setSelectingCorrectPost (false)
|
|
|
|
|
setSaved (false)
|
|
|
|
|
setResultWon (null)
|
|
|
|
|
setRejectedPostIds (new Set ())
|
|
|
|
|
setLastGuessQuestionCount (0)
|
|
|
|
|
setLastRejectedGuessId (null)
|
|
|
|
|
setActiveGuessId (null)
|
|
|
|
|
setReviewGuessedPostId (null)
|
|
|
|
|
setReviewCorrectPostId (null)
|
|
|
|
|
setHistory ([])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const recoverQuestionState = ({
|
|
|
|
|
nextAnswers,
|
|
|
|
|
nextAskedIds,
|
|
|
|
|
nextAskedQuestionBank,
|
|
|
|
|
nextSoftenedQuestionIds,
|
|
|
|
|
nextRejectedPostIds,
|
|
|
|
|
}: {
|
|
|
|
|
nextAnswers: GekanatorAnswerLog[]
|
|
|
|
|
nextAskedIds: Set<string>
|
|
|
|
|
nextAskedQuestionBank: GekanatorQuestion[]
|
|
|
|
|
nextSoftenedQuestionIds: Set<string>
|
|
|
|
|
nextRejectedPostIds: Set<number>
|
|
|
|
|
}) => {
|
|
|
|
|
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
|
|
|
|
|
let recoveredScores = recalculateScores ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextAskedQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds })
|
|
|
|
|
let recoveredEligiblePosts = candidatePostsFor ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextAskedQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
|
|
|
|
rejectedPostIds: nextRejectedPostIds })
|
|
|
|
|
let recoveredScoringQuestions = mergeQuestions ([
|
|
|
|
|
...buildGekanatorQuestions (
|
|
|
|
|
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
|
|
|
|
...nextAskedQuestionBank])
|
|
|
|
|
|
|
|
|
|
while (
|
|
|
|
|
recoveredEligiblePosts.length === 0
|
|
|
|
|
|| (
|
|
|
|
|
recoveredEligiblePosts.length !== 1
|
|
|
|
|
&& !(chooseQuestion ({
|
|
|
|
|
posts: recoveredEligiblePosts,
|
|
|
|
|
questions: recoveredScoringQuestions,
|
|
|
|
|
scores: recoveredScores,
|
|
|
|
|
askedIds: nextAskedIds })))
|
|
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
if (nextAnswers.length >= hardMaxQuestions)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
const softened = softenNextQuestionIds ({
|
|
|
|
|
questions: nextAskedQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds })
|
|
|
|
|
if (!(softened))
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
recoveredSoftenedQuestionIds = softened
|
|
|
|
|
recoveredScores = recalculateScores ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextAskedQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds })
|
|
|
|
|
recoveredEligiblePosts = candidatePostsFor ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextAskedQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
|
|
|
|
rejectedPostIds: nextRejectedPostIds })
|
|
|
|
|
recoveredScoringQuestions = mergeQuestions ([
|
|
|
|
|
...buildGekanatorQuestions (
|
|
|
|
|
recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
|
|
|
|
|
...nextAskedQuestionBank])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
|
|
|
|
scores: recoveredScores,
|
|
|
|
|
eligiblePosts: recoveredEligiblePosts,
|
|
|
|
|
scoringQuestions: recoveredScoringQuestions }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const answer = (value: GekanatorAnswerValue) => {
|
|
|
|
|
if (!(currentQuestion))
|
|
|
|
|
{
|
|
|
|
@@ -454,92 +588,66 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
scores: new Map (scores),
|
|
|
|
|
answers: [...answers],
|
|
|
|
|
askedIds: new Set (askedIds),
|
|
|
|
|
candidateIds: candidateIds === null ? null : new Set (candidateIds),
|
|
|
|
|
softenedQuestionIds: new Set (softenedQuestionIds),
|
|
|
|
|
questionBank: [...questionBank],
|
|
|
|
|
askedQuestionBank: [...askedQuestionBank],
|
|
|
|
|
search,
|
|
|
|
|
selectingCorrectPost,
|
|
|
|
|
rejectedPostIds: new Set (rejectedPostIds),
|
|
|
|
|
lastGuessQuestionCount,
|
|
|
|
|
lastRejectedGuessId,
|
|
|
|
|
activeGuessId }])
|
|
|
|
|
activeGuessId,
|
|
|
|
|
reviewGuessedPostId,
|
|
|
|
|
reviewCorrectPostId }])
|
|
|
|
|
const nextAnswers = [...answers, {
|
|
|
|
|
questionId: currentQuestion.id,
|
|
|
|
|
questionText: currentQuestion.text,
|
|
|
|
|
answer: value }]
|
|
|
|
|
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
|
|
|
|
const nextQuestionBank = [
|
|
|
|
|
...questionBank.filter (question => question.id !== currentQuestion.id),
|
|
|
|
|
const nextAskedQuestionBank = [
|
|
|
|
|
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
|
|
|
|
currentQuestion]
|
|
|
|
|
const hardFilteredPosts =
|
|
|
|
|
value === 'yes'
|
|
|
|
|
? eligiblePosts.filter (post => currentQuestion.test (post))
|
|
|
|
|
: value === 'no'
|
|
|
|
|
? eligiblePosts.filter (post => !(currentQuestion.test (post)))
|
|
|
|
|
: eligiblePosts
|
|
|
|
|
let nextCandidateIds =
|
|
|
|
|
(value === 'yes' || value === 'no') && hardFilteredPosts.length > 0
|
|
|
|
|
? new Set (hardFilteredPosts.map (post => post.id))
|
|
|
|
|
: candidateIds
|
|
|
|
|
let nextSoftenedQuestionIds = new Set (softenedQuestionIds)
|
|
|
|
|
let nextScores = recalculateScores ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
|
|
|
|
|
|
|
|
|
let nextEligiblePosts =
|
|
|
|
|
posts.filter (post =>
|
|
|
|
|
(nextCandidateIds === null || nextCandidateIds.has (post.id))
|
|
|
|
|
&& !(rejectedPostIds.has (post.id)))
|
|
|
|
|
let nextScoringQuestions = mergeQuestions ([
|
|
|
|
|
...buildGekanatorQuestions (nextEligiblePosts.length > 1 ? nextEligiblePosts : posts),
|
|
|
|
|
...nextQuestionBank])
|
|
|
|
|
while (
|
|
|
|
|
nextAnswers.length < hardMaxQuestions
|
|
|
|
|
&& nextEligiblePosts.length > 1
|
|
|
|
|
&& !(chooseQuestion ({
|
|
|
|
|
posts: nextEligiblePosts,
|
|
|
|
|
questions: nextScoringQuestions,
|
|
|
|
|
scores: nextScores,
|
|
|
|
|
askedIds: nextAskedIds }))
|
|
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
const softened = softenNextQuestionIds ({
|
|
|
|
|
questions: nextQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
|
|
|
|
if (!(softened))
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
nextSoftenedQuestionIds = softened
|
|
|
|
|
nextCandidateIds = null
|
|
|
|
|
nextEligiblePosts = posts.filter (post => !(rejectedPostIds.has (post.id)))
|
|
|
|
|
nextScoringQuestions = mergeQuestions ([
|
|
|
|
|
...buildGekanatorQuestions (nextEligiblePosts),
|
|
|
|
|
...nextQuestionBank])
|
|
|
|
|
nextScores = recalculateScores ({
|
|
|
|
|
posts,
|
|
|
|
|
questions: nextQuestionBank,
|
|
|
|
|
answers: nextAnswers,
|
|
|
|
|
softenedQuestionIds: nextSoftenedQuestionIds })
|
|
|
|
|
}
|
|
|
|
|
const recovered = recoverQuestionState ({
|
|
|
|
|
nextAnswers,
|
|
|
|
|
nextAskedIds,
|
|
|
|
|
nextAskedQuestionBank,
|
|
|
|
|
nextSoftenedQuestionIds: softenedQuestionIds,
|
|
|
|
|
nextRejectedPostIds: rejectedPostIds })
|
|
|
|
|
const nextSoftenedQuestionIds = recovered.softenedQuestionIds
|
|
|
|
|
const nextScores = recovered.scores
|
|
|
|
|
const nextEligiblePosts = recovered.eligiblePosts
|
|
|
|
|
|
|
|
|
|
setScores (nextScores)
|
|
|
|
|
setAskedIds (nextAskedIds)
|
|
|
|
|
setCandidateIds (nextCandidateIds)
|
|
|
|
|
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
|
|
|
|
setQuestionBank (nextQuestionBank)
|
|
|
|
|
setAskedQuestionBank (nextAskedQuestionBank)
|
|
|
|
|
setAnswers (nextAnswers)
|
|
|
|
|
|
|
|
|
|
const nextGuess = bestPost (nextEligiblePosts, nextScores)
|
|
|
|
|
const nextGuessablePosts =
|
|
|
|
|
nextEligiblePosts.length > 0
|
|
|
|
|
? nextEligiblePosts
|
|
|
|
|
: nonRejectedPosts
|
|
|
|
|
const nextGuess = bestPost (nextGuessablePosts, nextScores)
|
|
|
|
|
const nextQuestionCount = answers.length + 1
|
|
|
|
|
const definitelyKnown = nextEligiblePosts.length === 1
|
|
|
|
|
const enoughQuestions =
|
|
|
|
|
nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses
|
|
|
|
|
const nextQuestionsSinceLastGuess =
|
|
|
|
|
nextQuestionCount - lastGuessQuestionCount
|
|
|
|
|
const nextConfidences = confidencesFor (nextGuessablePosts, nextScores)
|
|
|
|
|
const topConfidence = nextConfidences[0] ?? null
|
|
|
|
|
const runnerUpConfidence = nextConfidences[1] ?? null
|
|
|
|
|
const structurallyCertain = nextEligiblePosts.length === 1
|
|
|
|
|
const statisticallyCertain =
|
|
|
|
|
topConfidence !== null
|
|
|
|
|
&& topConfidence.percent >= certainGuessPercent
|
|
|
|
|
&& (runnerUpConfidence === null
|
|
|
|
|
|| runnerUpConfidence.percent <= runnerUpMaxPercent)
|
|
|
|
|
const canGuessByQuestionCount =
|
|
|
|
|
nextQuestionsSinceLastGuess >= questionsBetweenGuesses
|
|
|
|
|
const canGuessEarlyByConfidence =
|
|
|
|
|
nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess
|
|
|
|
|
&& (structurallyCertain || statisticallyCertain)
|
|
|
|
|
const shouldGuess =
|
|
|
|
|
nextQuestionCount >= hardMaxQuestions
|
|
|
|
|
|| definitelyKnown
|
|
|
|
|
|| enoughQuestions
|
|
|
|
|
|| canGuessByQuestionCount
|
|
|
|
|
|| canGuessEarlyByConfidence
|
|
|
|
|
if (shouldGuess)
|
|
|
|
|
{
|
|
|
|
|
setActiveGuessId (nextGuess?.id ?? null)
|
|
|
|
@@ -548,20 +656,35 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveResult = (won: boolean, correctPostId: number | null) => {
|
|
|
|
|
const startReview = (correctPostId: number) => {
|
|
|
|
|
const guessedPostId =
|
|
|
|
|
won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id
|
|
|
|
|
if (!(guessedPostId) || saved)
|
|
|
|
|
phase === 'continue'
|
|
|
|
|
? lastRejectedGuessId ?? displayedGuess?.id
|
|
|
|
|
: displayedGuess?.id ?? lastRejectedGuessId
|
|
|
|
|
if (!(guessedPostId))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
saveMutation.reset ()
|
|
|
|
|
setReviewGuessedPostId (guessedPostId)
|
|
|
|
|
setReviewCorrectPostId (correctPostId)
|
|
|
|
|
setSearch ('')
|
|
|
|
|
setSelectingCorrectPost (false)
|
|
|
|
|
setPhase ('review')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveReviewedResult = () => {
|
|
|
|
|
if (
|
|
|
|
|
reviewGuessedPostId === null
|
|
|
|
|
|| reviewCorrectPostId === null
|
|
|
|
|
|| saveMutation.isPending
|
|
|
|
|
|| saved
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
setSaved (true)
|
|
|
|
|
setResultWon (won)
|
|
|
|
|
saveMutation.mutate ({
|
|
|
|
|
guessedPostId,
|
|
|
|
|
correctPostId,
|
|
|
|
|
won,
|
|
|
|
|
guessedPostId: reviewGuessedPostId,
|
|
|
|
|
correctPostId: reviewCorrectPostId,
|
|
|
|
|
answers })
|
|
|
|
|
setPhase ('learned')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rejectGuess = () => {
|
|
|
|
@@ -571,13 +694,14 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
setLastRejectedGuessId (displayedGuess.id)
|
|
|
|
|
if (answers.length >= hardMaxQuestions)
|
|
|
|
|
{
|
|
|
|
|
setSearch (' ')
|
|
|
|
|
setSelectingCorrectPost (true)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
|
|
|
|
setActiveGuessId (null)
|
|
|
|
|
setSearch ('')
|
|
|
|
|
setSelectingCorrectPost (false)
|
|
|
|
|
setLastGuessQuestionCount (answers.length)
|
|
|
|
|
setPhase ('continue')
|
|
|
|
|
}
|
|
|
|
@@ -591,30 +715,66 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
setScores (snapshot.scores)
|
|
|
|
|
setAnswers (snapshot.answers)
|
|
|
|
|
setAskedIds (snapshot.askedIds)
|
|
|
|
|
setCandidateIds (snapshot.candidateIds)
|
|
|
|
|
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
|
|
|
|
setQuestionBank (snapshot.questionBank)
|
|
|
|
|
setAskedQuestionBank (snapshot.askedQuestionBank)
|
|
|
|
|
setSearch (snapshot.search)
|
|
|
|
|
setSelectingCorrectPost (snapshot.selectingCorrectPost)
|
|
|
|
|
setRejectedPostIds (snapshot.rejectedPostIds)
|
|
|
|
|
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
|
|
|
|
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
|
|
|
|
setActiveGuessId (snapshot.activeGuessId)
|
|
|
|
|
setReviewGuessedPostId (snapshot.reviewGuessedPostId)
|
|
|
|
|
setReviewCorrectPostId (snapshot.reviewCorrectPostId)
|
|
|
|
|
setHistory (history.slice (0, -1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const softenAndContinue = () => {
|
|
|
|
|
const softened = softenNextQuestionIds ({
|
|
|
|
|
questions: scoringQuestions, answers, softenedQuestionIds })
|
|
|
|
|
if (!(softened))
|
|
|
|
|
return
|
|
|
|
|
const continueGame = () => {
|
|
|
|
|
setSearch ('')
|
|
|
|
|
setSelectingCorrectPost (false)
|
|
|
|
|
|
|
|
|
|
setSoftenedQuestionIds (softened)
|
|
|
|
|
setCandidateIds (null)
|
|
|
|
|
setScores (
|
|
|
|
|
recalculateScores ({ posts,
|
|
|
|
|
questions: scoringQuestions,
|
|
|
|
|
answers,
|
|
|
|
|
softenedQuestionIds: softened }))
|
|
|
|
|
const recovered = recoverQuestionState ({
|
|
|
|
|
nextAnswers: answers,
|
|
|
|
|
nextAskedIds: askedIds,
|
|
|
|
|
nextAskedQuestionBank: askedQuestionBank,
|
|
|
|
|
nextSoftenedQuestionIds: softenedQuestionIds,
|
|
|
|
|
nextRejectedPostIds: rejectedPostIds })
|
|
|
|
|
|
|
|
|
|
setSoftenedQuestionIds (recovered.softenedQuestionIds)
|
|
|
|
|
setScores (recovered.scores)
|
|
|
|
|
|
|
|
|
|
const nextQuestion = chooseQuestion ({
|
|
|
|
|
posts: recovered.eligiblePosts.length > 1
|
|
|
|
|
? recovered.eligiblePosts
|
|
|
|
|
: nonRejectedPosts,
|
|
|
|
|
questions: recovered.scoringQuestions,
|
|
|
|
|
scores: recovered.scores,
|
|
|
|
|
askedIds })
|
|
|
|
|
|
|
|
|
|
if (nextQuestion)
|
|
|
|
|
{
|
|
|
|
|
setPhase ('question')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setActiveGuessId (guess?.id ?? null)
|
|
|
|
|
setPhase ('guess')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
|
|
|
|
setAnswers (answers.map ((answer, i) =>
|
|
|
|
|
i === index ? { ...answer, answer: value } : answer))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectCorrectPost = (post: Post) => {
|
|
|
|
|
if (phase === 'review')
|
|
|
|
|
{
|
|
|
|
|
setReviewCorrectPostId (post.id)
|
|
|
|
|
setSelectingCorrectPost (false)
|
|
|
|
|
setSearch ('')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startReview (post.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredPosts = posts
|
|
|
|
@@ -646,6 +806,7 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
return (
|
|
|
|
|
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
|
|
|
|
<Helmet>
|
|
|
|
|
<meta name="robots" content="noindex"/>
|
|
|
|
|
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
|
|
|
|
</Helmet>
|
|
|
|
|
|
|
|
|
@@ -745,10 +906,8 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
{phase === 'question' && !(currentQuestion) && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<p className="text-xl font-bold">
|
|
|
|
|
さっきまでの答へを少し疑って考へ直すよ.
|
|
|
|
|
もう十分わかった。
|
|
|
|
|
</p>
|
|
|
|
|
{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1
|
|
|
|
|
? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded border border-yellow-300 px-4 py-2
|
|
|
|
@@ -758,16 +917,8 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
setActiveGuessId (guess?.id ?? null)
|
|
|
|
|
setPhase ('guess')
|
|
|
|
|
}}>
|
|
|
|
|
推測へ
|
|
|
|
|
</button>)
|
|
|
|
|
: (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
|
|
|
hover:bg-pink-500"
|
|
|
|
|
onClick={softenAndContinue}>
|
|
|
|
|
考へ直す
|
|
|
|
|
</button>)}
|
|
|
|
|
答える
|
|
|
|
|
</button>
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
|
|
{phase === 'guess' && displayedGuess && (
|
|
|
|
@@ -779,7 +930,10 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
|
|
|
hover:bg-pink-500"
|
|
|
|
|
onClick={() => saveResult (true, null)}>
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (displayedGuess)
|
|
|
|
|
startReview (displayedGuess.id)
|
|
|
|
|
}}>
|
|
|
|
|
当たり
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
@@ -800,6 +954,10 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
戻る
|
|
|
|
|
</button>)}
|
|
|
|
|
</div>
|
|
|
|
|
{saveMutation.isError && (
|
|
|
|
|
<p className="text-sm text-red-600">
|
|
|
|
|
学習ログの保存に失敗しました。もう一度試せます。
|
|
|
|
|
</p>)}
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
|
|
{phase === 'continue' && (
|
|
|
|
@@ -810,7 +968,7 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
|
|
|
hover:bg-pink-500"
|
|
|
|
|
onClick={() => setPhase ('question')}>
|
|
|
|
|
onClick={continueGame}>
|
|
|
|
|
はい
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
@@ -818,7 +976,7 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
className="rounded border border-yellow-300 px-4 py-2
|
|
|
|
|
hover:bg-yellow-100 dark:border-red-700
|
|
|
|
|
dark:hover:bg-red-900"
|
|
|
|
|
onClick={() => setSearch (' ')}>
|
|
|
|
|
onClick={() => setSelectingCorrectPost (true)}>
|
|
|
|
|
いいえ
|
|
|
|
|
</button>
|
|
|
|
|
{history.length > 0 && (
|
|
|
|
@@ -833,13 +991,98 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
</div>
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
|
|
{phase === 'review' && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-neutral-500">保存前確認</p>
|
|
|
|
|
<p className="text-xl font-bold">今回の結果を確認してね。</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{reviewGuessedPost && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="font-bold">推測した投稿</div>
|
|
|
|
|
<PostMiniCard post={reviewGuessedPost}/>
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="font-bold">正解の投稿</div>
|
|
|
|
|
{reviewCorrectPost
|
|
|
|
|
? <PostMiniCard post={reviewCorrectPost}/>
|
|
|
|
|
: <p className="text-sm text-red-600">正解投稿を選んでください。</p>}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded border border-yellow-300 px-3 py-2
|
|
|
|
|
hover:bg-yellow-100 dark:border-red-700
|
|
|
|
|
dark:hover:bg-red-900"
|
|
|
|
|
onClick={() => setSelectingCorrectPost (true)}>
|
|
|
|
|
正解投稿を変更
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="font-bold">質問と回答</div>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{answers.map ((answer, index) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${ answer.questionId }:${ index }`}
|
|
|
|
|
className="rounded border border-yellow-100 p-3
|
|
|
|
|
dark:border-red-900">
|
|
|
|
|
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
|
|
|
質問 {index + 1}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="font-bold">{answer.questionText}</div>
|
|
|
|
|
<select
|
|
|
|
|
value={answer.answer}
|
|
|
|
|
className="mt-2 rounded border border-yellow-300 bg-white px-2 py-1
|
|
|
|
|
dark:border-red-700 dark:bg-red-950"
|
|
|
|
|
onChange={ev =>
|
|
|
|
|
correctAnswerAt (
|
|
|
|
|
index,
|
|
|
|
|
ev.target.value as GekanatorAnswerValue)}>
|
|
|
|
|
{answerOptions.map (option => (
|
|
|
|
|
<option key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{reviewGuessedPostId !== null && reviewCorrectPostId !== null && (
|
|
|
|
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
|
|
|
|
判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'}
|
|
|
|
|
</p>)}
|
|
|
|
|
|
|
|
|
|
{saveMutation.isError && (
|
|
|
|
|
<p className="text-sm text-red-600">
|
|
|
|
|
学習ログの保存に失敗しました。もう一度試せます。
|
|
|
|
|
</p>)}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
|
|
|
hover:bg-pink-500 disabled:opacity-50"
|
|
|
|
|
disabled={
|
|
|
|
|
reviewCorrectPostId === null || saveMutation.isPending || saved
|
|
|
|
|
}
|
|
|
|
|
onClick={saveReviewedResult}>
|
|
|
|
|
保存
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded border border-neutral-300 px-4 py-2
|
|
|
|
|
hover:bg-neutral-100 dark:border-neutral-700
|
|
|
|
|
dark:hover:bg-red-900"
|
|
|
|
|
onClick={() => setPhase ('guess')}>
|
|
|
|
|
戻る
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>)}
|
|
|
|
|
|
|
|
|
|
{phase === 'learned' && (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<p>覚えたよ.次はもっと見通す.</p>
|
|
|
|
|
{saveMutation.isError && (
|
|
|
|
|
<p className="text-sm text-red-600">
|
|
|
|
|
ただし学習ログの保存には失敗しました.
|
|
|
|
|
</p>)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
|
|
@@ -852,7 +1095,8 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{['guess', 'continue', 'question'].includes (phase) && search !== '' && (
|
|
|
|
|
{['guess', 'continue', 'question', 'review'].includes (phase)
|
|
|
|
|
&& selectingCorrectPost && (
|
|
|
|
|
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
|
|
|
|
dark:border-red-800 dark:bg-red-950">
|
|
|
|
|
<label className="block space-y-2">
|
|
|
|
@@ -872,10 +1116,14 @@ const GekanatorPage: FC = () => {
|
|
|
|
|
className={cn ('block w-full rounded border border-yellow-200 p-3',
|
|
|
|
|
'text-left hover:bg-yellow-100',
|
|
|
|
|
'dark:border-red-800 dark:hover:bg-red-900')}
|
|
|
|
|
onClick={() => saveResult (false, post.id)}>
|
|
|
|
|
onClick={() => selectCorrectPost (post)}>
|
|
|
|
|
<PostMiniCard post={post}/>
|
|
|
|
|
</button>))}
|
|
|
|
|
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
|
|
|
|
{saveMutation.isError && (
|
|
|
|
|
<p className="text-sm text-red-600">
|
|
|
|
|
学習ログの保存に失敗しました。もう一度試せます。
|
|
|
|
|
</p>)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>)}
|
|
|
|
|
</div>
|
|
|
|
|