From de21141f5a02bdec85a176775858fd33bfb47c03 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 8 Jun 2026 08:41:52 +0900 Subject: [PATCH] #41 --- .../controllers/gekanator_games_controller.rb | 2 +- backend/app/models/gekanator_game.rb | 10 +- backend/spec/requests/gekanator_games_spec.rb | 14 +- frontend/src/lib/gekanator.ts | 108 ++-- frontend/src/pages/GekanatorPage.tsx | 574 +++++++++++++----- 5 files changed, 468 insertions(+), 240 deletions(-) diff --git a/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 5e2e1d6..abc9c16 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -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:) diff --git a/backend/app/models/gekanator_game.rb b/backend/app/models/gekanator_game.rb index 5102a33..dad5819 100644 --- a/backend/app/models/gekanator_game.rb +++ b/backend/app/models/gekanator_game.rb @@ -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 diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 117269d..4db3d69 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -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: [] } diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 8ce3e67..8e64139 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -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 = (counts: Map) => [...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, diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 5fb059a..b72ab99 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -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 answers: GekanatorAnswerLog[] askedIds: Set - candidateIds: Set | null softenedQuestionIds: Set - questionBank: GekanatorQuestion[] + askedQuestionBank: GekanatorQuestion[] search: string + selectingCorrectPost: boolean rejectedPostIds: Set 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 + rejectedPostIds: Set +}): 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): 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> (new Map ()) const [answers, setAnswers] = useState ([]) const [askedIds, setAskedIds] = useState> (new Set ()) - const [candidateIds, setCandidateIds] = useState | null> (null) const [softenedQuestionIds, setSoftenedQuestionIds] = useState> (new Set ()) - const [questionBank, setQuestionBank] = useState ([]) + const [askedQuestionBank, setAskedQuestionBank] = useState ([]) const [search, setSearch] = useState ('') + const [selectingCorrectPost, setSelectingCorrectPost] = useState (false) const [saved, setSaved] = useState (false) const [resultWon, setResultWon] = useState (null) const [rejectedPostIds, setRejectedPostIds] = useState> (new Set ()) const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0) const [lastRejectedGuessId, setLastRejectedGuessId] = useState (null) const [activeGuessId, setActiveGuessId] = useState (null) + const [reviewGuessedPostId, setReviewGuessedPostId] = useState (null) + const [reviewCorrectPostId, setReviewCorrectPostId] = useState (null) const [history, setHistory] = useState ([]) 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 + nextAskedQuestionBank: GekanatorQuestion[] + nextSoftenedQuestionIds: Set + nextRejectedPostIds: Set + }) => { + 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 ( + {`グカネータ | ${ SITE_TITLE }`} @@ -745,29 +906,19 @@ const GekanatorPage: FC = () => { {phase === 'question' && !(currentQuestion) && (

- さっきまでの答へを少し疑って考へ直すよ. + もう十分わかった。

- {answers.length >= hardMaxQuestions || eligiblePosts.length <= 1 - ? ( - ) - : ( - )} +
)} {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) + }}> 当たり )} + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)} )} {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}> はい {history.length > 0 && ( @@ -833,13 +991,98 @@ const GekanatorPage: FC = () => { )} + {phase === 'review' && ( +
+
+

保存前確認

+

今回の結果を確認してね。

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

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

} + +
+ +
+
質問と回答
+
+ {answers.map ((answer, index) => ( +
+
+ 質問 {index + 1} +
+
{answer.questionText}
+ +
))} +
+
+ + {reviewGuessedPostId !== null && reviewCorrectPostId !== null && ( +

+ 判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'} +

)} + + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)} + +
+ + +
+
)} + {phase === 'learned' && (

覚えたよ.次はもっと見通す.

- {saveMutation.isError && ( -

- ただし学習ログの保存には失敗しました. -

)} ))} {search.trim () && filteredPosts.length === 0 && '見つかりません.'} + {saveMutation.isError && ( +

+ 学習ログの保存に失敗しました。もう一度試せます。 +

)}
)}