グカネータ作成 / 質問パターン修正 (#41) #364

マージ済み
みてるぞ が 17 個のコミットを feature/041 から main へマージ 2026-06-11 23:21:45 +09:00
5個のファイルの変更468行の追加240行の削除
コミット de21141f5a の変更だけを表示してゐます - すべてのコミットを表示
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ class GekanatorGamesController < ApplicationController
user: current_user,
guessed_post_id: params.require(:guessed_post_id),
correct_post_id: params[:correct_post_id].presence,
won: bool?(:won),
won: params[:guessed_post_id].to_i == params[:correct_post_id].to_i,
question_count: params.require(:question_count),
answers:)
+1 -9
ファイルの表示
@@ -4,15 +4,7 @@ class GekanatorGame < ApplicationRecord
belongs_to :correct_post, class_name: 'Post', optional: true
validates :answers, presence: true
validates :correct_post, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
validates :won, inclusion: { in: [true, false] }
validate :correct_post_required_when_lost
private
def correct_post_required_when_lost
return if won || correct_post_id.present?
errors.add(:correct_post_id, '外れた時は正解の投稿を指定してくださぃ.')
end
end
+7 -7
ファイルの表示
@@ -11,7 +11,7 @@ RSpec.describe 'Gekanator games API', type: :request do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
won: true,
correct_post_id: guessed_post.id,
question_count: 3,
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
@@ -19,7 +19,7 @@ RSpec.describe 'Gekanator games API', type: :request do
game = GekanatorGame.find(json['id'])
expect(game.user).to eq(user)
expect(game.guessed_post).to eq(guessed_post)
expect(game.correct_post).to be_nil
expect(game.correct_post).to eq(guessed_post)
expect(game.won).to eq(true)
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
end
@@ -30,20 +30,20 @@ RSpec.describe 'Gekanator games API', type: :request do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
won: false,
question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] }
expect(response).to have_http_status(:created)
expect(GekanatorGame.find(json['id']).correct_post).to eq(correct_post)
game = GekanatorGame.find(json['id'])
expect(game.correct_post).to eq(correct_post)
expect(game.won).to eq(false)
end
it 'rejects a lost game without the correct post' do
it 'rejects a game without the correct post' do
sign_in_as user
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
won: false,
question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] }
@@ -53,7 +53,7 @@ RSpec.describe 'Gekanator games API', type: :request do
it 'requires a user' do
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
won: true,
correct_post_id: guessed_post.id,
question_count: 1,
answers: [] }
+48 -60
ファイルの表示
@@ -17,11 +17,9 @@ export type GekanatorAnswerLog = {
export type GekanatorQuestionKind =
| 'tag'
| 'title'
| 'date'
| 'media'
| 'source'
| 'structure'
| 'title'
| 'original_date'
export type GekanatorQuestion = {
id: string
@@ -54,6 +52,19 @@ const hostOf = (post: Post): string | null => {
}
const originalYearOf = (post: Post): number | null => {
const value = post.originalCreatedFrom || post.originalCreatedBefore
if (!(value))
return null
const date = new Date (value)
if (Number.isNaN (date.getTime ()))
return null
return date.getFullYear ()
}
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
`${ category }:${ name }`
@@ -113,9 +124,11 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
&& !(tag.name.includes ('bot操作')))
.map (tag => tagQuestionKey (tag))))
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
const tagMedian = median (posts.map (post => post.tags.length))
const originalYears = countBy (
posts
.map (originalYearOf)
.filter ((year): year is number => year !== null))
const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
const currentYear = new Date ().getFullYear ()
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
[...counts.entries ()]
@@ -144,63 +157,41 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([host]) => ({
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
test: (post: Post) => hostOf (post) === host }))
id: `source:${ host }`,
text: `${ host } の投稿を思ひ浮かべてゐる?`,
kind: 'source' as const,
test: (post: Post) => hostOf (post) === host }))
return [
...sourceQuestions,
{
id: 'title:present',
text: '題名が付いてゐる投稿?',
kind: 'title',
test: post => Boolean (post.title) },
const originalYearQuestions = usefulEntries (originalYears)
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
.slice (0, 20)
.map (([year]) => ({
id: `original-year:${ year }`,
text: `オリジナルの投稿年は ${ year } 年?`,
kind: 'original_date' as const,
test: (post: Post) => originalYearOf (post) === year }))
const titleQuestions = [
{
id: 'title:long',
text: '題名が長めの投稿?',
kind: 'title',
test: post => (post.title?.length ?? 0) > titleLengthMedian },
kind: 'title' as const,
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
{
id: 'title:ascii',
text: '題名に英数字が混じってゐる?',
kind: 'title',
test: post => /[A-Za-z0-9]/.test (post.title ?? '') },
{
id: 'media:thumbnail',
text: 'ぱっと見でサムネが付いてゐる投稿?',
kind: 'media',
test: post => Boolean (post.thumbnail || post.thumbnailBase) },
{
id: 'media:video-source',
text: '動画として見られる投稿?',
kind: 'media',
test: post => /nicovideo|youtube|youtu\.be/.test (post.url) },
{
id: 'structure:many-tags',
text: 'タグが多めに付いてゐる投稿?',
kind: 'structure',
test: post => post.tags.length > tagMedian },
{
id: 'structure:no-title',
text: '題名がまだ付いてゐない投稿?',
kind: 'structure',
test: post => !(post.title) },
{
id: 'date:recent',
text: '最近追加されたほうの投稿?',
kind: 'date',
test: post => new Date (post.createdAt).getFullYear () >= currentYear - 1 },
{
id: 'date:old',
text: 'むかし追加されたほうの投稿?',
kind: 'date',
test: post => new Date (post.createdAt).getFullYear () <= currentYear - 3 },
{
id: 'date:original-known',
text: 'オリジナルの投稿日時が分かってゐる投稿?',
kind: 'date',
test: post => Boolean (post.originalCreatedFrom || post.originalCreatedBefore) },
kind: 'title' as const,
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
.filter (question => {
const yes = posts.filter (post => question.test (post)).length
const no = posts.length - yes
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
})
return [
...sourceQuestions,
...originalYearQuestions,
...titleQuestions,
...tagQuestions]
}
@@ -208,18 +199,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
export const saveGekanatorGame = async ({
guessedPostId,
correctPostId,
won,
answers,
}: {
guessedPostId: number
correctPostId: number | null
won: boolean
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
won,
question_count: answers.length,
answers: answers.map (answer => ({
question_id: answer.questionId,
+411 -163
ファイルの表示
@@ -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,29 +906,19 @@ 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
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => {
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
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"
onClick={() => {
setActiveGuessId (guess?.id ?? null)
setPhase ('guess')
}}>
</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>