このコミットが含まれているのは:
2026-06-10 20:02:08 +09:00
コミット 7fe7dbd909
14個のファイルの変更606行の追加41行の削除
+262 -22
ファイルの表示
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
fetchGekanatorPosts,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion } from '@/lib/gekanator'
@@ -19,6 +22,7 @@ import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorExtraQuestion,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
@@ -31,6 +35,7 @@ type Phase =
| 'end'
| 'review'
| 'question_suggestion'
| 'extra_questions'
| 'learned'
type AnswerOption = {
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
gameSeed?: string
questionSuggestion: string
questionSuggestionAnswer: GekanatorAnswerValue
questionSuggestionCount?: number }
questionSuggestionCount?: number
extraQuestions?: GekanatorExtraQuestion[]
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
const resettableExtraQuestionState = (): {
extraQuestions: GekanatorExtraQuestion[]
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
extraQuestionState: 'idle'
} => ({
extraQuestions: [],
extraQuestionAnswers: { },
extraQuestionState: 'idle' })
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
switch (answer)
{
@@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
}
const answerScalarFor = (
answer: GekanatorAnswerValue | null,
): number | null => {
switch (answer)
{
case 'yes':
return 1
case 'partial':
return .5
case 'probably_no':
return -.5
case 'no':
return -1
case 'unknown':
case null:
return null
}
}
const deltaForExpectedAnswer = (
expected: GekanatorAnswerValue | null,
answer: GekanatorAnswerValue,
): number => {
if (answer === 'unknown' || expected === null || expected === 'unknown')
return 0
if (expected === 'yes' || expected === 'no')
return deltaFor (expected === 'yes', answer)
const expectedScalar = answerScalarFor (expected)
const answerScalar = answerScalarFor (answer)
if (expectedScalar === null || answerScalar === null)
return 0
const distance = Math.abs (expectedScalar - answerScalar)
if (distance >= 2)
return -4
if (distance >= 1.5)
return -2
if (distance >= 1)
return 0
if (distance >= .5)
return 2
return 4
}
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
@@ -277,10 +344,11 @@ const recalculateScores = ({
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
posts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0)
+ deltaFor (question.test (post), answer.answer) * weight)
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
})
})
@@ -318,9 +386,10 @@ const candidatePostsFor = ({
switch (answer.answer)
{
case 'yes':
return question.test (post)
case 'no':
return !(question.test (post))
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
@@ -378,20 +447,19 @@ const previewAnswer = ({
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'yes'
? posts.filter (post => question.test (post))
: answer === 'no'
? posts.filter (post => !(question.test (post)))
: posts
answer === 'unknown'
? posts
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
const nextPosts =
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
answer !== 'unknown' && hardFilteredPosts.length > 0
? hardFilteredPosts
: posts
const nextScores = new Map (scores)
nextPosts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
})
const confidences = confidencesFor (nextPosts, nextScores)
@@ -497,6 +565,8 @@ const sameConditionValue = (
return String (condition.length)
case 'title-has-ascii':
return ''
case 'post-similarity':
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
}
}
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(correctPost))
return null
return question.test (correctPost) ? 'yes' : 'no'
}
): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
const GekanatorPage: FC = () => {
const storedGame = useMemo (loadStoredGame, [])
const queryClient = useQueryClient ()
const [gameSeed, setGameSeed] = useState (
storedGame?.gameSeed ?? createGameSeed ())
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
storedGame?.questionSuggestionCount ?? 0)
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
storedGame?.extraQuestions ?? [])
const [extraQuestionAnswers, setExtraQuestionAnswers] =
useState<Record<string, GekanatorAnswerValue>> (
storedGame?.extraQuestionAnswers ?? { })
const [extraQuestionState, setExtraQuestionState] = useState<
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
> (storedGame?.extraQuestionState ?? 'idle')
const [history, setHistory] = useState<GameSnapshot[]> ([])
const { data: posts = [], isLoading, error } = useQuery ({
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount }
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState }
try
{
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount])
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: () => {
setExtraQuestionState ('saved')
setPhase ('learned')
}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
setExtraQuestions (next.extraQuestions)
setExtraQuestionAnswers (next.extraQuestionAnswers)
setExtraQuestionState (next.extraQuestionState)
extraQuestionAnswersMutation.reset ()
}
const reset = () => {
clearStoredGame ()
saveMutation.reset ()
questionSuggestionMutation.reset ()
setPhase ('intro')
setScores (new Map ())
setAnswers ([])
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
setQuestionSuggestionCount (0)
resetExtraQuestionState ()
setHistory ([])
}
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId)
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false)
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
}
const saveAndLearn = () => {
resetExtraQuestionState ()
saveReviewedResult (() => setPhase ('learned'))
}
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
{
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
})
.slice (0, 20)
const loadExtraQuestions = async (gameId: number) => {
extraQuestionAnswersMutation.reset ()
setExtraQuestionState ('loading')
setExtraQuestions ([])
setExtraQuestionAnswers ({ })
setPhase ('extra_questions')
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId),
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
setExtraQuestions (questions)
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
}
catch
{
setExtraQuestionState ('empty')
}
}
const startExtraQuestions = () => {
if (reviewCorrectPostId === null || saveMutation.isPending)
return
saveReviewedResult (gameId => {
void loadExtraQuestions (gameId)
})
}
const answerExtraQuestion = (
questionId: number,
value: GekanatorAnswerValue,
) => {
setExtraQuestionAnswers ({
...extraQuestionAnswers,
[String (questionId)]: value })
}
const saveExtraQuestions = () => {
if (
savedGameId === null
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
)
return
extraQuestionAnswersMutation.mutate ({
gameId: savedGameId,
answers: extraQuestions.map (question => ({
questionId: question.id,
answer: extraQuestionAnswers[String (question.id)] })) })
}
const dialogue =
phase === 'learned' && resultWon
? <>wwwww <ruby>鹿<rt></rt></ruby>!</>
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
onClick={() => setPhase ('question_suggestion')}>
</button>
<button
type="button"
className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900 disabled:opacity-50"
disabled={reviewCorrectPostId === null
|| saveMutation.isPending
|| extraQuestionState === 'loading'
|| extraQuestionAnswersMutation.isPending}
onClick={startExtraQuestions}>
</button>
</div>
</div>)}
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
</p>)}
</div>)}
{phase === 'extra_questions' && (
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"> 2 </p>
</div>
{extraQuestionState === 'loading' && (
<p>...</p>)}
{extraQuestionState === 'empty' && (
<p></p>)}
{extraQuestionState === 'ready' && (
<div className="space-y-3">
{extraQuestions.map ((question, index) => (
<div
key={question.id}
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">{question.text}</div>
<div className="mt-3 flex flex-wrap gap-2">
{answerOptions.map (option => (
<button
key={option.value}
type="button"
className={cn (
'rounded border px-3 py-2',
extraQuestionAnswers[String (question.id)] === option.value
? 'border-pink-600 bg-pink-600 text-white'
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
onClick={() => answerExtraQuestion (question.id, option.value)}>
{option.label}
</button>))}
</div>
</div>))}
</div>)}
{extraQuestionAnswersMutation.isError && (
<p className="text-sm text-red-600">
</p>)}
<div className="flex flex-wrap gap-2">
<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"
disabled={extraQuestionAnswersMutation.isPending}
onClick={() => setPhase ('end')}>
</button>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
hover:bg-pink-500 disabled:opacity-50"
disabled={
extraQuestionState !== 'ready'
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (
question => !(extraQuestionAnswers[String (question.id)]))
}
onClick={saveExtraQuestions}>
</button>
</div>
</div>)}
{phase === 'learned' && (
<div className="space-y-3">
<p></p>
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white