グカネータ作成 / ウィニング・ラン修正 (#41) #366
@@ -8,7 +8,7 @@ class GekanatorGamesController < ApplicationController
|
|||||||
user: current_user,
|
user: current_user,
|
||||||
guessed_post_id: params.require(:guessed_post_id),
|
guessed_post_id: params.require(:guessed_post_id),
|
||||||
correct_post_id: params[:correct_post_id].presence,
|
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),
|
question_count: params.require(:question_count),
|
||||||
answers:)
|
answers:)
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ class GekanatorGame < ApplicationRecord
|
|||||||
belongs_to :correct_post, class_name: 'Post', optional: true
|
belongs_to :correct_post, class_name: 'Post', optional: true
|
||||||
|
|
||||||
validates :answers, presence: true
|
validates :answers, presence: true
|
||||||
|
validates :correct_post, presence: true
|
||||||
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
|
||||||
validates :won, inclusion: { in: [true, false] }
|
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
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RSpec.describe 'Gekanator games API', type: :request do
|
|||||||
|
|
||||||
post '/gekanator/games', params: {
|
post '/gekanator/games', params: {
|
||||||
guessed_post_id: guessed_post.id,
|
guessed_post_id: guessed_post.id,
|
||||||
won: true,
|
correct_post_id: guessed_post.id,
|
||||||
question_count: 3,
|
question_count: 3,
|
||||||
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
answers: [{ question_id: 'tag:1', answer: 'yes' }] }
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ RSpec.describe 'Gekanator games API', type: :request do
|
|||||||
game = GekanatorGame.find(json['id'])
|
game = GekanatorGame.find(json['id'])
|
||||||
expect(game.user).to eq(user)
|
expect(game.user).to eq(user)
|
||||||
expect(game.guessed_post).to eq(guessed_post)
|
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.won).to eq(true)
|
||||||
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
expect(game.answers).to eq([{ 'question_id' => 'tag:1', 'answer' => 'yes' }])
|
||||||
end
|
end
|
||||||
@@ -30,20 +30,20 @@ RSpec.describe 'Gekanator games API', type: :request do
|
|||||||
post '/gekanator/games', params: {
|
post '/gekanator/games', params: {
|
||||||
guessed_post_id: guessed_post.id,
|
guessed_post_id: guessed_post.id,
|
||||||
correct_post_id: correct_post.id,
|
correct_post_id: correct_post.id,
|
||||||
won: false,
|
|
||||||
question_count: 4,
|
question_count: 4,
|
||||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
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
|
end
|
||||||
|
|
||||||
it 'rejects a lost game without the correct post' do
|
it 'rejects a game without the correct post' do
|
||||||
sign_in_as user
|
sign_in_as user
|
||||||
|
|
||||||
post '/gekanator/games', params: {
|
post '/gekanator/games', params: {
|
||||||
guessed_post_id: guessed_post.id,
|
guessed_post_id: guessed_post.id,
|
||||||
won: false,
|
|
||||||
question_count: 4,
|
question_count: 4,
|
||||||
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
answers: [{ question_id: 'tag:1', answer: 'no' }] }
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ RSpec.describe 'Gekanator games API', type: :request do
|
|||||||
it 'requires a user' do
|
it 'requires a user' do
|
||||||
post '/gekanator/games', params: {
|
post '/gekanator/games', params: {
|
||||||
guessed_post_id: guessed_post.id,
|
guessed_post_id: guessed_post.id,
|
||||||
won: true,
|
correct_post_id: guessed_post.id,
|
||||||
question_count: 1,
|
question_count: 1,
|
||||||
answers: [] }
|
answers: [] }
|
||||||
|
|
||||||
|
|||||||
+48
-60
@@ -17,11 +17,9 @@ export type GekanatorAnswerLog = {
|
|||||||
|
|
||||||
export type GekanatorQuestionKind =
|
export type GekanatorQuestionKind =
|
||||||
| 'tag'
|
| 'tag'
|
||||||
| 'title'
|
|
||||||
| 'date'
|
|
||||||
| 'media'
|
|
||||||
| 'source'
|
| 'source'
|
||||||
| 'structure'
|
| 'title'
|
||||||
|
| 'original_date'
|
||||||
|
|
||||||
export type GekanatorQuestion = {
|
export type GekanatorQuestion = {
|
||||||
id: string
|
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 =>
|
const tagQuestionKey = ({ category, name }: { category: string; name: string }): string =>
|
||||||
`${ category }:${ name }`
|
`${ category }:${ name }`
|
||||||
|
|
||||||
@@ -113,9 +124,11 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
&& !(tag.name.includes ('bot操作')))
|
&& !(tag.name.includes ('bot操作')))
|
||||||
.map (tag => tagQuestionKey (tag))))
|
.map (tag => tagQuestionKey (tag))))
|
||||||
const hosts = countBy (posts.map (hostOf).filter ((host): host is string => Boolean (host)))
|
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 titleLengthMedian = median (posts.map (post => post.title?.length ?? 0))
|
||||||
const currentYear = new Date ().getFullYear ()
|
|
||||||
|
|
||||||
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
|
const usefulEntries = <T extends string | number> (counts: Map<T, number>) =>
|
||||||
[...counts.entries ()]
|
[...counts.entries ()]
|
||||||
@@ -144,63 +157,41 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
.slice (0, 20)
|
.slice (0, 20)
|
||||||
.map (([host]) => ({
|
.map (([host]) => ({
|
||||||
id: `source:${ host }`,
|
id: `source:${ host }`,
|
||||||
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
text: `${ host } の投稿を思ひ浮かべてゐる?`,
|
||||||
kind: 'source' as const,
|
kind: 'source' as const,
|
||||||
test: (post: Post) => hostOf (post) === host }))
|
test: (post: Post) => hostOf (post) === host }))
|
||||||
|
|
||||||
return [
|
const originalYearQuestions = usefulEntries (originalYears)
|
||||||
...sourceQuestions,
|
.filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7))
|
||||||
{
|
.slice (0, 20)
|
||||||
id: 'title:present',
|
.map (([year]) => ({
|
||||||
text: '題名が付いてゐる投稿?',
|
id: `original-year:${ year }`,
|
||||||
kind: 'title',
|
text: `オリジナルの投稿年は ${ year } 年?`,
|
||||||
test: post => Boolean (post.title) },
|
kind: 'original_date' as const,
|
||||||
|
test: (post: Post) => originalYearOf (post) === year }))
|
||||||
|
|
||||||
|
const titleQuestions = [
|
||||||
{
|
{
|
||||||
id: 'title:long',
|
id: 'title:long',
|
||||||
text: '題名が長めの投稿?',
|
text: '題名が長めの投稿?',
|
||||||
kind: 'title',
|
kind: 'title' as const,
|
||||||
test: post => (post.title?.length ?? 0) > titleLengthMedian },
|
test: (post: Post) => (post.title?.length ?? 0) > titleLengthMedian },
|
||||||
{
|
{
|
||||||
id: 'title:ascii',
|
id: 'title:ascii',
|
||||||
text: '題名に英数字が混じってゐる?',
|
text: '題名に英数字が混じってゐる?',
|
||||||
kind: 'title',
|
kind: 'title' as const,
|
||||||
test: post => /[A-Za-z0-9]/.test (post.title ?? '') },
|
test: (post: Post) => /[A-Za-z0-9]/.test (post.title ?? '') }]
|
||||||
{
|
.filter (question => {
|
||||||
id: 'media:thumbnail',
|
const yes = posts.filter (post => question.test (post)).length
|
||||||
text: 'ぱっと見でサムネが付いてゐる投稿?',
|
const no = posts.length - yes
|
||||||
kind: 'media',
|
return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7
|
||||||
test: post => Boolean (post.thumbnail || post.thumbnailBase) },
|
})
|
||||||
{
|
|
||||||
id: 'media:video-source',
|
return [
|
||||||
text: '動画として見られる投稿?',
|
...sourceQuestions,
|
||||||
kind: 'media',
|
...originalYearQuestions,
|
||||||
test: post => /nicovideo|youtube|youtu\.be/.test (post.url) },
|
...titleQuestions,
|
||||||
{
|
|
||||||
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) },
|
|
||||||
...tagQuestions]
|
...tagQuestions]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,18 +199,15 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
|
|||||||
export const saveGekanatorGame = async ({
|
export const saveGekanatorGame = async ({
|
||||||
guessedPostId,
|
guessedPostId,
|
||||||
correctPostId,
|
correctPostId,
|
||||||
won,
|
|
||||||
answers,
|
answers,
|
||||||
}: {
|
}: {
|
||||||
guessedPostId: number
|
guessedPostId: number
|
||||||
correctPostId: number | null
|
correctPostId: number
|
||||||
won: boolean
|
|
||||||
answers: GekanatorAnswerLog[]
|
answers: GekanatorAnswerLog[]
|
||||||
}): Promise<{ id: number }> =>
|
}): Promise<{ id: number }> =>
|
||||||
await apiPost ('/gekanator/games', {
|
await apiPost ('/gekanator/games', {
|
||||||
guessed_post_id: guessedPostId,
|
guessed_post_id: guessedPostId,
|
||||||
correct_post_id: correctPostId,
|
correct_post_id: correctPostId,
|
||||||
won,
|
|
||||||
question_count: answers.length,
|
question_count: answers.length,
|
||||||
answers: answers.map (answer => ({
|
answers: answers.map (answer => ({
|
||||||
question_id: answer.questionId,
|
question_id: answer.questionId,
|
||||||
|
|||||||
+411
-163
@@ -18,7 +18,7 @@ import type { GekanatorAnswerLog,
|
|||||||
GekanatorQuestion } from '@/lib/gekanator'
|
GekanatorQuestion } from '@/lib/gekanator'
|
||||||
import type { Post } from '@/types'
|
import type { Post } from '@/types'
|
||||||
|
|
||||||
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'learned'
|
type Phase = 'intro' | 'question' | 'guess' | 'continue' | 'review' | 'learned'
|
||||||
|
|
||||||
type AnswerOption = {
|
type AnswerOption = {
|
||||||
label: string
|
label: string
|
||||||
@@ -41,14 +41,16 @@ type GameSnapshot = {
|
|||||||
scores: Map<number, number>
|
scores: Map<number, number>
|
||||||
answers: GekanatorAnswerLog[]
|
answers: GekanatorAnswerLog[]
|
||||||
askedIds: Set<string>
|
askedIds: Set<string>
|
||||||
candidateIds: Set<number> | null
|
|
||||||
softenedQuestionIds: Set<string>
|
softenedQuestionIds: Set<string>
|
||||||
questionBank: GekanatorQuestion[]
|
askedQuestionBank: GekanatorQuestion[]
|
||||||
search: string
|
search: string
|
||||||
|
selectingCorrectPost: boolean
|
||||||
rejectedPostIds: Set<number>
|
rejectedPostIds: Set<number>
|
||||||
lastGuessQuestionCount: number
|
lastGuessQuestionCount: number
|
||||||
lastRejectedGuessId: number | null
|
lastRejectedGuessId: number | null
|
||||||
activeGuessId: number | null }
|
activeGuessId: number | null
|
||||||
|
reviewGuessedPostId: number | null
|
||||||
|
reviewCorrectPostId: number | null }
|
||||||
|
|
||||||
const answerOptions: AnswerOption[] = [
|
const answerOptions: AnswerOption[] = [
|
||||||
{ label: 'はい', value: 'yes' },
|
{ label: 'はい', value: 'yes' },
|
||||||
@@ -58,6 +60,9 @@ const answerOptions: AnswerOption[] = [
|
|||||||
{ label: 'わからない', value: 'unknown' }]
|
{ label: 'わからない', value: 'unknown' }]
|
||||||
|
|
||||||
const questionsBetweenGuesses = 25
|
const questionsBetweenGuesses = 25
|
||||||
|
const minQuestionsBeforeCertainGuess = 5
|
||||||
|
const certainGuessPercent = 99.5
|
||||||
|
const runnerUpMaxPercent = .5
|
||||||
const hardMaxQuestions = 80
|
const hardMaxQuestions = 80
|
||||||
const softenedAnswerWeight = .35
|
const softenedAnswerWeight = .35
|
||||||
const confidenceTemperature = 6
|
const confidenceTemperature = 6
|
||||||
@@ -87,18 +92,14 @@ const answerWeightFor = (
|
|||||||
|
|
||||||
|
|
||||||
const questionDifficulty = (question: GekanatorQuestion): number => {
|
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')
|
if (question.kind === 'source')
|
||||||
return 4
|
return 4
|
||||||
|
if (question.kind === 'original_date')
|
||||||
|
return 4
|
||||||
|
if (question.kind === 'title')
|
||||||
|
return 4
|
||||||
if (question.kind === 'tag')
|
if (question.kind === 'tag')
|
||||||
return 3
|
return 3
|
||||||
if (question.kind === 'title' || question.kind === 'structure')
|
|
||||||
return 2
|
|
||||||
|
|
||||||
return 1
|
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[] => {
|
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
|
||||||
if (posts.length === 0)
|
if (posts.length === 0)
|
||||||
return []
|
return []
|
||||||
@@ -306,26 +348,12 @@ const chooseQuestion = ({
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
const splitScore = Math.abs (candidates.length / 2 - yes)
|
const splitScore = Math.abs (candidates.length / 2 - yes)
|
||||||
const answerPreviews = answerOptions.map (option =>
|
const tagPenalty = question.kind === 'tag' ? 0 : 20
|
||||||
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 minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||||||
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
const narrowPenalty = yes < minSide || no < minSide ? candidates.length : 0
|
||||||
|
|
||||||
return { question,
|
return { question,
|
||||||
score: splitScore + expectedEntropy + expectedCandidateCount / 8
|
score: splitScore + tagPenalty + narrowPenalty,
|
||||||
+ kindPenalty + tagPenalty + narrowPenalty,
|
|
||||||
narrow: narrowPenalty > 0 }
|
narrow: narrowPenalty > 0 }
|
||||||
})
|
})
|
||||||
.filter ((item): item is {
|
.filter ((item): item is {
|
||||||
@@ -373,34 +401,47 @@ const GekanatorPage: FC = () => {
|
|||||||
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
const [scores, setScores] = useState<Map<number, number>> (new Map ())
|
||||||
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> ([])
|
||||||
const [askedIds, setAskedIds] = useState<Set<string>> (new Set ())
|
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 [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (new Set ())
|
||||||
const [questionBank, setQuestionBank] = useState<GekanatorQuestion[]> ([])
|
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> ([])
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
|
const [selectingCorrectPost, setSelectingCorrectPost] = useState (false)
|
||||||
const [saved, setSaved] = useState (false)
|
const [saved, setSaved] = useState (false)
|
||||||
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
const [resultWon, setResultWon] = useState<boolean | null> (null)
|
||||||
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (new Set ())
|
||||||
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (0)
|
||||||
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (null)
|
||||||
const [activeGuessId, setActiveGuessId] = 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 [history, setHistory] = useState<GameSnapshot[]> ([])
|
||||||
|
|
||||||
const { data: posts = [], isLoading, error } = useQuery ({
|
const { data: posts = [], isLoading, error } = useQuery ({
|
||||||
queryKey: gekanatorKeys.posts (),
|
queryKey: gekanatorKeys.posts (),
|
||||||
queryFn: fetchGekanatorPosts })
|
queryFn: fetchGekanatorPosts })
|
||||||
|
|
||||||
const candidatePosts = useMemo (
|
|
||||||
() => posts.filter (post => candidateIds === null || candidateIds.has (post.id)),
|
|
||||||
[posts, candidateIds])
|
|
||||||
const eligiblePosts = useMemo (
|
const eligiblePosts = useMemo (
|
||||||
() => candidatePosts.filter (post => !(rejectedPostIds.has (post.id))),
|
() => candidatePostsFor ({
|
||||||
[candidatePosts, rejectedPostIds])
|
posts,
|
||||||
|
questions: askedQuestionBank,
|
||||||
|
answers,
|
||||||
|
softenedQuestionIds,
|
||||||
|
rejectedPostIds }),
|
||||||
|
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
||||||
const questions = useMemo (
|
const questions = useMemo (
|
||||||
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
() => buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||||||
[eligiblePosts, posts])
|
[eligiblePosts, posts])
|
||||||
const scoringQuestions = useMemo (() => {
|
const scoringQuestions = useMemo (() => {
|
||||||
return mergeQuestions ([...questions, ...questionBank])
|
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||||||
}, [questions, questionBank])
|
}, [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 (
|
const topScoredPosts = useMemo (
|
||||||
() => eligiblePosts
|
() => eligiblePosts
|
||||||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||||||
@@ -408,7 +449,7 @@ const GekanatorPage: FC = () => {
|
|||||||
.slice (0, 3),
|
.slice (0, 3),
|
||||||
[eligiblePosts, scores])
|
[eligiblePosts, scores])
|
||||||
const currentQuestion = chooseQuestion ({
|
const currentQuestion = chooseQuestion ({
|
||||||
posts: eligiblePosts, questions: scoringQuestions, scores, askedIds })
|
posts: questionPosts, questions: scoringQuestions, scores, askedIds })
|
||||||
const answerPreviews = useMemo (
|
const answerPreviews = useMemo (
|
||||||
() => currentQuestion
|
() => currentQuestion
|
||||||
? answerOptions.map (option => previewAnswer ({
|
? answerOptions.map (option => previewAnswer ({
|
||||||
@@ -418,29 +459,122 @@ const GekanatorPage: FC = () => {
|
|||||||
answer: option.value }))
|
answer: option.value }))
|
||||||
: [],
|
: [],
|
||||||
[currentQuestion, eligiblePosts, scores])
|
[currentQuestion, eligiblePosts, scores])
|
||||||
const guess = bestPost (eligiblePosts, scores)
|
const guessablePosts =
|
||||||
|
eligiblePosts.length > 0
|
||||||
|
? eligiblePosts
|
||||||
|
: nonRejectedPosts
|
||||||
|
const guess = bestPost (guessablePosts, scores)
|
||||||
const displayedGuess =
|
const displayedGuess =
|
||||||
posts.find (post => post.id === activeGuessId) ?? guess
|
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 = () => {
|
const reset = () => {
|
||||||
|
saveMutation.reset ()
|
||||||
setPhase ('intro')
|
setPhase ('intro')
|
||||||
setScores (new Map ())
|
setScores (new Map ())
|
||||||
setAnswers ([])
|
setAnswers ([])
|
||||||
setAskedIds (new Set ())
|
setAskedIds (new Set ())
|
||||||
setCandidateIds (null)
|
|
||||||
setSoftenedQuestionIds (new Set ())
|
setSoftenedQuestionIds (new Set ())
|
||||||
setQuestionBank ([])
|
setAskedQuestionBank ([])
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
|
setSelectingCorrectPost (false)
|
||||||
setSaved (false)
|
setSaved (false)
|
||||||
setResultWon (null)
|
setResultWon (null)
|
||||||
setRejectedPostIds (new Set ())
|
setRejectedPostIds (new Set ())
|
||||||
setLastGuessQuestionCount (0)
|
setLastGuessQuestionCount (0)
|
||||||
setLastRejectedGuessId (null)
|
setLastRejectedGuessId (null)
|
||||||
setActiveGuessId (null)
|
setActiveGuessId (null)
|
||||||
|
setReviewGuessedPostId (null)
|
||||||
|
setReviewCorrectPostId (null)
|
||||||
setHistory ([])
|
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) => {
|
const answer = (value: GekanatorAnswerValue) => {
|
||||||
if (!(currentQuestion))
|
if (!(currentQuestion))
|
||||||
{
|
{
|
||||||
@@ -454,92 +588,66 @@ const GekanatorPage: FC = () => {
|
|||||||
scores: new Map (scores),
|
scores: new Map (scores),
|
||||||
answers: [...answers],
|
answers: [...answers],
|
||||||
askedIds: new Set (askedIds),
|
askedIds: new Set (askedIds),
|
||||||
candidateIds: candidateIds === null ? null : new Set (candidateIds),
|
|
||||||
softenedQuestionIds: new Set (softenedQuestionIds),
|
softenedQuestionIds: new Set (softenedQuestionIds),
|
||||||
questionBank: [...questionBank],
|
askedQuestionBank: [...askedQuestionBank],
|
||||||
search,
|
search,
|
||||||
|
selectingCorrectPost,
|
||||||
rejectedPostIds: new Set (rejectedPostIds),
|
rejectedPostIds: new Set (rejectedPostIds),
|
||||||
lastGuessQuestionCount,
|
lastGuessQuestionCount,
|
||||||
lastRejectedGuessId,
|
lastRejectedGuessId,
|
||||||
activeGuessId }])
|
activeGuessId,
|
||||||
|
reviewGuessedPostId,
|
||||||
|
reviewCorrectPostId }])
|
||||||
const nextAnswers = [...answers, {
|
const nextAnswers = [...answers, {
|
||||||
questionId: currentQuestion.id,
|
questionId: currentQuestion.id,
|
||||||
questionText: currentQuestion.text,
|
questionText: currentQuestion.text,
|
||||||
answer: value }]
|
answer: value }]
|
||||||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||||||
const nextQuestionBank = [
|
const nextAskedQuestionBank = [
|
||||||
...questionBank.filter (question => question.id !== currentQuestion.id),
|
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
||||||
currentQuestion]
|
currentQuestion]
|
||||||
const hardFilteredPosts =
|
const recovered = recoverQuestionState ({
|
||||||
value === 'yes'
|
nextAnswers,
|
||||||
? eligiblePosts.filter (post => currentQuestion.test (post))
|
nextAskedIds,
|
||||||
: value === 'no'
|
nextAskedQuestionBank,
|
||||||
? eligiblePosts.filter (post => !(currentQuestion.test (post)))
|
nextSoftenedQuestionIds: softenedQuestionIds,
|
||||||
: eligiblePosts
|
nextRejectedPostIds: rejectedPostIds })
|
||||||
let nextCandidateIds =
|
const nextSoftenedQuestionIds = recovered.softenedQuestionIds
|
||||||
(value === 'yes' || value === 'no') && hardFilteredPosts.length > 0
|
const nextScores = recovered.scores
|
||||||
? new Set (hardFilteredPosts.map (post => post.id))
|
const nextEligiblePosts = recovered.eligiblePosts
|
||||||
: 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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
setScores (nextScores)
|
setScores (nextScores)
|
||||||
setAskedIds (nextAskedIds)
|
setAskedIds (nextAskedIds)
|
||||||
setCandidateIds (nextCandidateIds)
|
|
||||||
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
||||||
setQuestionBank (nextQuestionBank)
|
setAskedQuestionBank (nextAskedQuestionBank)
|
||||||
setAnswers (nextAnswers)
|
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 nextQuestionCount = answers.length + 1
|
||||||
const definitelyKnown = nextEligiblePosts.length === 1
|
const nextQuestionsSinceLastGuess =
|
||||||
const enoughQuestions =
|
nextQuestionCount - lastGuessQuestionCount
|
||||||
nextQuestionCount - lastGuessQuestionCount >= questionsBetweenGuesses
|
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 =
|
const shouldGuess =
|
||||||
nextQuestionCount >= hardMaxQuestions
|
nextQuestionCount >= hardMaxQuestions
|
||||||
|| definitelyKnown
|
|| canGuessByQuestionCount
|
||||||
|| enoughQuestions
|
|| canGuessEarlyByConfidence
|
||||||
if (shouldGuess)
|
if (shouldGuess)
|
||||||
{
|
{
|
||||||
setActiveGuessId (nextGuess?.id ?? null)
|
setActiveGuessId (nextGuess?.id ?? null)
|
||||||
@@ -548,20 +656,35 @@ const GekanatorPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveResult = (won: boolean, correctPostId: number | null) => {
|
const startReview = (correctPostId: number) => {
|
||||||
const guessedPostId =
|
const guessedPostId =
|
||||||
won ? displayedGuess?.id : lastRejectedGuessId ?? displayedGuess?.id
|
phase === 'continue'
|
||||||
if (!(guessedPostId) || saved)
|
? 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
|
return
|
||||||
|
|
||||||
setSaved (true)
|
|
||||||
setResultWon (won)
|
|
||||||
saveMutation.mutate ({
|
saveMutation.mutate ({
|
||||||
guessedPostId,
|
guessedPostId: reviewGuessedPostId,
|
||||||
correctPostId,
|
correctPostId: reviewCorrectPostId,
|
||||||
won,
|
|
||||||
answers })
|
answers })
|
||||||
setPhase ('learned')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectGuess = () => {
|
const rejectGuess = () => {
|
||||||
@@ -571,13 +694,14 @@ const GekanatorPage: FC = () => {
|
|||||||
setLastRejectedGuessId (displayedGuess.id)
|
setLastRejectedGuessId (displayedGuess.id)
|
||||||
if (answers.length >= hardMaxQuestions)
|
if (answers.length >= hardMaxQuestions)
|
||||||
{
|
{
|
||||||
setSearch (' ')
|
setSelectingCorrectPost (true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
||||||
setActiveGuessId (null)
|
setActiveGuessId (null)
|
||||||
setSearch ('')
|
setSearch ('')
|
||||||
|
setSelectingCorrectPost (false)
|
||||||
setLastGuessQuestionCount (answers.length)
|
setLastGuessQuestionCount (answers.length)
|
||||||
setPhase ('continue')
|
setPhase ('continue')
|
||||||
}
|
}
|
||||||
@@ -591,30 +715,66 @@ const GekanatorPage: FC = () => {
|
|||||||
setScores (snapshot.scores)
|
setScores (snapshot.scores)
|
||||||
setAnswers (snapshot.answers)
|
setAnswers (snapshot.answers)
|
||||||
setAskedIds (snapshot.askedIds)
|
setAskedIds (snapshot.askedIds)
|
||||||
setCandidateIds (snapshot.candidateIds)
|
|
||||||
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
||||||
setQuestionBank (snapshot.questionBank)
|
setAskedQuestionBank (snapshot.askedQuestionBank)
|
||||||
setSearch (snapshot.search)
|
setSearch (snapshot.search)
|
||||||
|
setSelectingCorrectPost (snapshot.selectingCorrectPost)
|
||||||
setRejectedPostIds (snapshot.rejectedPostIds)
|
setRejectedPostIds (snapshot.rejectedPostIds)
|
||||||
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
||||||
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
||||||
setActiveGuessId (snapshot.activeGuessId)
|
setActiveGuessId (snapshot.activeGuessId)
|
||||||
|
setReviewGuessedPostId (snapshot.reviewGuessedPostId)
|
||||||
|
setReviewCorrectPostId (snapshot.reviewCorrectPostId)
|
||||||
setHistory (history.slice (0, -1))
|
setHistory (history.slice (0, -1))
|
||||||
}
|
}
|
||||||
|
|
||||||
const softenAndContinue = () => {
|
const continueGame = () => {
|
||||||
const softened = softenNextQuestionIds ({
|
setSearch ('')
|
||||||
questions: scoringQuestions, answers, softenedQuestionIds })
|
setSelectingCorrectPost (false)
|
||||||
if (!(softened))
|
|
||||||
return
|
|
||||||
|
|
||||||
setSoftenedQuestionIds (softened)
|
const recovered = recoverQuestionState ({
|
||||||
setCandidateIds (null)
|
nextAnswers: answers,
|
||||||
setScores (
|
nextAskedIds: askedIds,
|
||||||
recalculateScores ({ posts,
|
nextAskedQuestionBank: askedQuestionBank,
|
||||||
questions: scoringQuestions,
|
nextSoftenedQuestionIds: softenedQuestionIds,
|
||||||
answers,
|
nextRejectedPostIds: rejectedPostIds })
|
||||||
softenedQuestionIds: softened }))
|
|
||||||
|
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
|
const filteredPosts = posts
|
||||||
@@ -646,6 +806,7 @@ const GekanatorPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
<meta name="robots" content="noindex"/>
|
||||||
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
@@ -745,29 +906,19 @@ const GekanatorPage: FC = () => {
|
|||||||
{phase === 'question' && !(currentQuestion) && (
|
{phase === 'question' && !(currentQuestion) && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xl font-bold">
|
<p className="text-xl font-bold">
|
||||||
さっきまでの答へを少し疑って考へ直すよ.
|
もう十分わかった。
|
||||||
</p>
|
</p>
|
||||||
{answers.length >= hardMaxQuestions || eligiblePosts.length <= 1
|
<button
|
||||||
? (
|
type="button"
|
||||||
<button
|
className="rounded border border-yellow-300 px-4 py-2
|
||||||
type="button"
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
className="rounded border border-yellow-300 px-4 py-2
|
dark:hover:bg-red-900"
|
||||||
hover:bg-yellow-100 dark:border-red-700
|
onClick={() => {
|
||||||
dark:hover:bg-red-900"
|
setActiveGuessId (guess?.id ?? null)
|
||||||
onClick={() => {
|
setPhase ('guess')
|
||||||
setActiveGuessId (guess?.id ?? null)
|
}}>
|
||||||
setPhase ('guess')
|
答える
|
||||||
}}>
|
</button>
|
||||||
推測へ
|
|
||||||
</button>)
|
|
||||||
: (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
|
||||||
hover:bg-pink-500"
|
|
||||||
onClick={softenAndContinue}>
|
|
||||||
考へ直す
|
|
||||||
</button>)}
|
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{phase === 'guess' && displayedGuess && (
|
{phase === 'guess' && displayedGuess && (
|
||||||
@@ -779,7 +930,10 @@ const GekanatorPage: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
hover:bg-pink-500"
|
hover:bg-pink-500"
|
||||||
onClick={() => saveResult (true, null)}>
|
onClick={() => {
|
||||||
|
if (displayedGuess)
|
||||||
|
startReview (displayedGuess.id)
|
||||||
|
}}>
|
||||||
当たり
|
当たり
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -800,6 +954,10 @@ const GekanatorPage: FC = () => {
|
|||||||
戻る
|
戻る
|
||||||
</button>)}
|
</button>)}
|
||||||
</div>
|
</div>
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
学習ログの保存に失敗しました。もう一度試せます。
|
||||||
|
</p>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{phase === 'continue' && (
|
{phase === 'continue' && (
|
||||||
@@ -810,7 +968,7 @@ const GekanatorPage: FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
hover:bg-pink-500"
|
hover:bg-pink-500"
|
||||||
onClick={() => setPhase ('question')}>
|
onClick={continueGame}>
|
||||||
はい
|
はい
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -818,7 +976,7 @@ const GekanatorPage: FC = () => {
|
|||||||
className="rounded border border-yellow-300 px-4 py-2
|
className="rounded border border-yellow-300 px-4 py-2
|
||||||
hover:bg-yellow-100 dark:border-red-700
|
hover:bg-yellow-100 dark:border-red-700
|
||||||
dark:hover:bg-red-900"
|
dark:hover:bg-red-900"
|
||||||
onClick={() => setSearch (' ')}>
|
onClick={() => setSelectingCorrectPost (true)}>
|
||||||
いいえ
|
いいえ
|
||||||
</button>
|
</button>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
@@ -833,13 +991,98 @@ const GekanatorPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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' && (
|
{phase === 'learned' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>覚えたよ.次はもっと見通す.</p>
|
<p>覚えたよ.次はもっと見通す.</p>
|
||||||
{saveMutation.isError && (
|
|
||||||
<p className="text-sm text-red-600">
|
|
||||||
ただし学習ログの保存には失敗しました.
|
|
||||||
</p>)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||||||
@@ -852,7 +1095,8 @@ const GekanatorPage: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||||||
dark:border-red-800 dark:bg-red-950">
|
dark:border-red-800 dark:bg-red-950">
|
||||||
<label className="block space-y-2">
|
<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',
|
className={cn ('block w-full rounded border border-yellow-200 p-3',
|
||||||
'text-left hover:bg-yellow-100',
|
'text-left hover:bg-yellow-100',
|
||||||
'dark:border-red-800 dark:hover:bg-red-900')}
|
'dark:border-red-800 dark:hover:bg-red-900')}
|
||||||
onClick={() => saveResult (false, post.id)}>
|
onClick={() => selectCorrectPost (post)}>
|
||||||
<PostMiniCard post={post}/>
|
<PostMiniCard post={post}/>
|
||||||
</button>))}
|
</button>))}
|
||||||
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
学習ログの保存に失敗しました。もう一度試せます。
|
||||||
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
</section>)}
|
</section>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする