このコミットが含まれているのは:
@@ -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:)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする