c361c561c2
Reviewed-on: #364 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
2234 行
67 KiB
TypeScript
2234 行
67 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { buildGekanatorQuestions,
|
||
expectedAnswerForQuestion,
|
||
fetchGekanatorExtraQuestions,
|
||
fetchGekanatorQuestions,
|
||
fetchGekanatorPosts,
|
||
normalizeTitleLengthCondition,
|
||
restoreGekanatorQuestion,
|
||
saveGekanatorExtraQuestionAnswers,
|
||
saveGekanatorGame,
|
||
saveGekanatorQuestionSuggestion,
|
||
storeGekanatorQuestion,
|
||
titleLengthMinimumForCondition } from '@/lib/gekanator'
|
||
import { gekanatorKeys } from '@/lib/queryKeys'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { GekanatorAnswerLog,
|
||
GekanatorAnswerValue,
|
||
GekanatorExtraQuestion,
|
||
GekanatorQuestionCondition,
|
||
GekanatorQuestion,
|
||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||
import type { Post } from '@/types'
|
||
|
||
type Phase =
|
||
| 'intro'
|
||
| 'question'
|
||
| 'guess'
|
||
| 'continue'
|
||
| 'end'
|
||
| 'review'
|
||
| 'question_suggestion'
|
||
| 'extra_questions'
|
||
| 'learned'
|
||
|
||
type AnswerOption = {
|
||
label: string
|
||
value: GekanatorAnswerValue }
|
||
|
||
type Confidence = {
|
||
post: Post
|
||
score: number
|
||
percent: number }
|
||
|
||
type AnswerPreview = {
|
||
answer: GekanatorAnswerValue
|
||
top: Confidence | null
|
||
candidateCount: number
|
||
effectiveCandidates: number
|
||
entropy: number }
|
||
|
||
type GameSnapshot = {
|
||
phase: Phase
|
||
scores: Map<number, number>
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
softenedQuestionIds: Set<string>
|
||
askedQuestionBank: GekanatorQuestion[]
|
||
search: string
|
||
selectingCorrectPost: boolean
|
||
rejectedPostIds: Set<number>
|
||
lastGuessQuestionCount: number
|
||
lastRejectedGuessId: number | null
|
||
activeGuessId: number | null
|
||
reviewGuessedPostId: number | null
|
||
reviewCorrectPostId: number | null }
|
||
|
||
type StoredGekanatorGame = {
|
||
phase: Phase
|
||
scores: [number, number][]
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: string[]
|
||
softenedQuestionIds: string[]
|
||
askedQuestionBank?: StoredGekanatorQuestion[]
|
||
askedQuestionBankIds?: string[]
|
||
search: string
|
||
selectingCorrectPost: boolean
|
||
saved: boolean
|
||
resultWon: boolean | null
|
||
rejectedPostIds: number[]
|
||
lastGuessQuestionCount: number
|
||
lastRejectedGuessId: number | null
|
||
activeGuessId: number | null
|
||
reviewGuessedPostId: number | null
|
||
reviewCorrectPostId: number | null
|
||
savedGameId: number | null
|
||
gameSeed?: string
|
||
questionSuggestion: string
|
||
questionSuggestionAnswer: GekanatorAnswerValue
|
||
questionSuggestionCount?: number
|
||
extraQuestions?: GekanatorExtraQuestion[]
|
||
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
|
||
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
|
||
|
||
const answerOptions: AnswerOption[] = [
|
||
{ label: 'はい', value: 'yes' },
|
||
{ label: 'いいえ', value: 'no' },
|
||
{ label: '部分的にそう', value: 'partial' },
|
||
{ label: 'たぶんいいえ', value: 'probably_no' },
|
||
{ label: 'わからない', value: 'unknown' }]
|
||
|
||
const answerLabelFor = (value: GekanatorAnswerValue): string =>
|
||
answerOptions.find (option => option.value === value)?.label ?? value
|
||
|
||
const questionsBetweenGuesses = 25
|
||
const minQuestionsBeforeCertainGuess = 5
|
||
const certainGuessPercent = 99.5
|
||
const runnerUpMaxPercent = .5
|
||
const hardMaxQuestions = 80
|
||
const softenedAnswerWeight = .35
|
||
const confidenceTemperature = 6
|
||
const gameStorageKey = 'gekanator:game:v1'
|
||
const maxQuestionSuggestionsPerGame = 3
|
||
|
||
const sourcePriorityOffset = (question: GekanatorQuestion): number => {
|
||
switch (question.source)
|
||
{
|
||
case 'user_suggested':
|
||
return -1.2
|
||
case 'admin_curated':
|
||
return -0.8
|
||
case 'ai_generated':
|
||
return -0.6
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
const priorityWeightOffset = (question: GekanatorQuestion): number =>
|
||
(Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8
|
||
|
||
|
||
const createGameSeed = (): string => {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||
return crypto.randomUUID ()
|
||
|
||
return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }`
|
||
}
|
||
|
||
|
||
const normalizeStoredQuestionId = (
|
||
questionId: string,
|
||
condition?: GekanatorQuestionCondition,
|
||
): string => {
|
||
if (condition?.type === 'title-length-greater-than')
|
||
return `title:length-at-least:${ condition.length + 1 }`
|
||
|
||
if (questionId.startsWith ('title:length-greater-than:'))
|
||
{
|
||
const length = Number (questionId.split (':').pop ())
|
||
if (Number.isInteger (length))
|
||
return `title:length-at-least:${ length + 1 }`
|
||
}
|
||
|
||
return questionId
|
||
}
|
||
|
||
|
||
const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({
|
||
...game,
|
||
answers: game.answers.map (answer => ({
|
||
...answer,
|
||
questionId: normalizeStoredQuestionId (
|
||
answer.questionId,
|
||
answer.questionCondition),
|
||
questionCondition: answer.questionCondition
|
||
? normalizeTitleLengthCondition (answer.questionCondition)
|
||
: undefined })),
|
||
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
|
||
softenedQuestionIds: game.softenedQuestionIds.map (questionId =>
|
||
normalizeStoredQuestionId (questionId)),
|
||
askedQuestionBank: game.askedQuestionBank?.map (question =>
|
||
({
|
||
...question,
|
||
id: normalizeStoredQuestionId (question.id, question.condition),
|
||
condition: normalizeTitleLengthCondition (question.condition) })),
|
||
askedQuestionBankIds: game.askedQuestionBankIds?.map (questionId =>
|
||
normalizeStoredQuestionId (questionId)) })
|
||
|
||
|
||
const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
|
||
switch (question.source)
|
||
{
|
||
case 'user_suggested':
|
||
return 3
|
||
case 'admin_curated':
|
||
return 3
|
||
case 'ai_generated':
|
||
return 3
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
|
||
const shouldReplaceMergedQuestion = (
|
||
current: GekanatorQuestion | undefined,
|
||
candidate: GekanatorQuestion,
|
||
): boolean => {
|
||
if (!(current))
|
||
return true
|
||
|
||
const currentSourcePriority = sourcePriorityForMerge (current)
|
||
const candidateSourcePriority = sourcePriorityForMerge (candidate)
|
||
if (candidateSourcePriority !== currentSourcePriority)
|
||
return candidateSourcePriority > currentSourcePriority
|
||
|
||
if (candidate.priorityWeight !== current.priorityWeight)
|
||
return candidate.priorityWeight > current.priorityWeight
|
||
|
||
return true
|
||
}
|
||
|
||
|
||
const hashString = (value: string): number => {
|
||
let hash = 2166136261
|
||
|
||
for (let i = 0; i < value.length; i += 1)
|
||
{
|
||
hash ^= value.charCodeAt (i)
|
||
hash = Math.imul (hash, 16777619)
|
||
}
|
||
|
||
return hash >>> 0
|
||
}
|
||
|
||
|
||
const deterministicUnitFloat = (seed: string): number =>
|
||
hashString (seed) / 4294967295
|
||
|
||
|
||
const clearStoredGame = (): void => {
|
||
try
|
||
{
|
||
sessionStorage.removeItem (gameStorageKey)
|
||
}
|
||
catch
|
||
{
|
||
return
|
||
}
|
||
}
|
||
|
||
|
||
const loadStoredGame = (): StoredGekanatorGame | null => {
|
||
try
|
||
{
|
||
const raw = sessionStorage.getItem (gameStorageKey)
|
||
if (!(raw))
|
||
return null
|
||
|
||
return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame)
|
||
}
|
||
catch
|
||
{
|
||
clearStoredGame ()
|
||
return null
|
||
}
|
||
}
|
||
|
||
|
||
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
|
||
|
||
|
||
const resettableExtraQuestionState = (): {
|
||
extraQuestions: GekanatorExtraQuestion[]
|
||
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||
extraQuestionState: 'idle'
|
||
} => ({
|
||
extraQuestions: [],
|
||
extraQuestionAnswers: { },
|
||
extraQuestionState: 'idle' })
|
||
|
||
|
||
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
|
||
switch (answer)
|
||
{
|
||
case 'yes':
|
||
return matched ? 4 : -4
|
||
case 'no':
|
||
return matched ? -4 : 4
|
||
case 'partial':
|
||
return matched ? 2 : -1
|
||
case 'probably_no':
|
||
return matched ? -2 : 2
|
||
case 'unknown':
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
const answerScalarFor = (
|
||
answer: GekanatorAnswerValue | null,
|
||
): number | null => {
|
||
switch (answer)
|
||
{
|
||
case 'yes':
|
||
return 1
|
||
case 'partial':
|
||
return .5
|
||
case 'probably_no':
|
||
return -.5
|
||
case 'no':
|
||
return -1
|
||
case 'unknown':
|
||
case null:
|
||
return null
|
||
}
|
||
}
|
||
|
||
|
||
const deltaForExpectedAnswer = (
|
||
expected: GekanatorAnswerValue | null,
|
||
answer: GekanatorAnswerValue,
|
||
): number => {
|
||
if (answer === 'unknown' || expected === null || expected === 'unknown')
|
||
return 0
|
||
|
||
if (expected === 'yes' || expected === 'no')
|
||
return deltaFor (expected === 'yes', answer)
|
||
|
||
const expectedScalar = answerScalarFor (expected)
|
||
const answerScalar = answerScalarFor (answer)
|
||
if (expectedScalar === null || answerScalar === null)
|
||
return 0
|
||
|
||
const distance = Math.abs (expectedScalar - answerScalar)
|
||
if (distance >= 2)
|
||
return -4
|
||
if (distance >= 1.5)
|
||
return -2
|
||
if (distance >= 1)
|
||
return 0
|
||
if (distance >= .5)
|
||
return 2
|
||
|
||
return 4
|
||
}
|
||
|
||
|
||
const answerWeightFor = (
|
||
questionId: string,
|
||
softenedQuestionIds: Set<string>,
|
||
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1
|
||
|
||
|
||
const questionDifficulty = (question: GekanatorQuestion): number => {
|
||
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
|
||
|
||
return 1
|
||
}
|
||
|
||
|
||
const recalculateScores = ({
|
||
posts,
|
||
questions,
|
||
answers,
|
||
softenedQuestionIds,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
}): Map<number, number> => {
|
||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||
const nextScores = new Map<number, number> ()
|
||
|
||
answers.forEach (answer => {
|
||
const question = questionById.get (answer.questionId)
|
||
if (!(question))
|
||
return
|
||
|
||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||
posts.forEach (post => {
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0)
|
||
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
|
||
})
|
||
})
|
||
|
||
return nextScores
|
||
}
|
||
|
||
|
||
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':
|
||
case 'no': {
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
return expected === null || expected === 'unknown' || expected === answer.answer
|
||
}
|
||
default:
|
||
return true
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
|
||
const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
|
||
if (posts.length === 0)
|
||
return []
|
||
|
||
const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
const maxScore = Math.max (...raw.map (({ score }) => score))
|
||
const weighted = raw.map (item => ({
|
||
...item,
|
||
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
|
||
const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||
|
||
return weighted
|
||
.map (({ post, score, weight }) => ({
|
||
post,
|
||
score,
|
||
percent: weight / total * 100 }))
|
||
.sort ((a, b) => b.percent - a.percent)
|
||
}
|
||
|
||
|
||
const entropyFor = (confidences: Confidence[]): number =>
|
||
confidences.reduce ((sum, item) => {
|
||
const p = item.percent / 100
|
||
return p > 0 ? sum - p * Math.log2 (p) : sum
|
||
}, 0)
|
||
|
||
|
||
const effectiveCandidatesFor = (confidences: Confidence[]): number => {
|
||
const concentration = confidences.reduce ((sum, item) => {
|
||
const p = item.percent / 100
|
||
return sum + p * p
|
||
}, 0)
|
||
|
||
return concentration > 0 ? 1 / concentration : 0
|
||
}
|
||
|
||
|
||
const previewAnswer = ({
|
||
posts,
|
||
scores,
|
||
question,
|
||
answer,
|
||
}: {
|
||
posts: Post[]
|
||
scores: Map<number, number>
|
||
question: GekanatorQuestion
|
||
answer: GekanatorAnswerValue
|
||
}): AnswerPreview => {
|
||
const hardFilteredPosts =
|
||
answer === 'unknown'
|
||
? posts
|
||
: posts.filter (post => {
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
return expected === null || expected === 'unknown' || expected === answer
|
||
})
|
||
const nextPosts =
|
||
hardFilteredPosts.length > 0
|
||
? hardFilteredPosts
|
||
: posts
|
||
const nextScores = new Map (scores)
|
||
nextPosts.forEach (post => {
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
|
||
})
|
||
|
||
const confidences = confidencesFor (nextPosts, nextScores)
|
||
|
||
return {
|
||
answer,
|
||
top: confidences[0] ?? null,
|
||
candidateCount: nextPosts.length,
|
||
effectiveCandidates: effectiveCandidatesFor (confidences),
|
||
entropy: entropyFor (confidences) }
|
||
}
|
||
|
||
|
||
const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
|
||
const byId = new Map<string, GekanatorQuestion> ()
|
||
questions.forEach (question => {
|
||
const current = byId.get (question.id)
|
||
if (shouldReplaceMergedQuestion (current, question))
|
||
byId.set (question.id, question)
|
||
})
|
||
return [...byId.values ()]
|
||
}
|
||
|
||
|
||
const softenNextQuestionIds = ({
|
||
questions,
|
||
answers,
|
||
softenedQuestionIds,
|
||
}: {
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
}): Set<string> | null => {
|
||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||
const candidate = [...answers]
|
||
.reverse ()
|
||
.map (answer => {
|
||
const question = questionById.get (answer.questionId)
|
||
return { answer, question }
|
||
})
|
||
.filter ((item): item is {
|
||
answer: GekanatorAnswerLog
|
||
question: GekanatorQuestion } =>
|
||
item.question !== undefined
|
||
&& item.answer.answer !== 'unknown'
|
||
&& !(softenedQuestionIds.has (item.answer.questionId)))
|
||
.sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0]
|
||
|
||
if (!(candidate))
|
||
return null
|
||
|
||
return new Set ([...softenedQuestionIds, candidate.answer.questionId])
|
||
}
|
||
|
||
|
||
type ExclusiveConditionGroup =
|
||
| 'original-month'
|
||
| 'original-year'
|
||
| 'original-month-day'
|
||
| 'source'
|
||
|
||
|
||
const exclusiveConditionGroupFor = (
|
||
condition: GekanatorQuestion['condition'],
|
||
): ExclusiveConditionGroup | null => {
|
||
switch (condition.type)
|
||
{
|
||
case 'original-month':
|
||
return 'original-month'
|
||
case 'original-year':
|
||
return 'original-year'
|
||
case 'original-month-day':
|
||
return 'original-month-day'
|
||
case 'source':
|
||
return 'source'
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
|
||
const sameConditionValue = (
|
||
left: GekanatorQuestion['condition'],
|
||
right: GekanatorQuestion['condition'],
|
||
): boolean => {
|
||
const leftTitleLength = titleLengthMinimumForCondition (left)
|
||
const rightTitleLength = titleLengthMinimumForCondition (right)
|
||
if (leftTitleLength !== null || rightTitleLength !== null)
|
||
return leftTitleLength !== null
|
||
&& rightTitleLength !== null
|
||
&& leftTitleLength === rightTitleLength
|
||
|
||
if (left.type !== right.type)
|
||
return false
|
||
|
||
const valueKeyFor = (condition: GekanatorQuestion['condition']): string => {
|
||
switch (condition.type)
|
||
{
|
||
case 'tag':
|
||
return condition.key
|
||
case 'source':
|
||
return condition.host
|
||
case 'original-year':
|
||
return String (condition.year)
|
||
case 'original-month':
|
||
return String (condition.month)
|
||
case 'original-month-day':
|
||
return condition.monthDay
|
||
case 'title-has-ascii':
|
||
return ''
|
||
case 'post-similarity':
|
||
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
|
||
case 'title-length-at-least':
|
||
case 'title-length-greater-than':
|
||
return String (titleLengthMinimumForCondition (condition) ?? '')
|
||
}
|
||
}
|
||
|
||
return valueKeyFor (left) === valueKeyFor (right)
|
||
}
|
||
|
||
|
||
const monthForCondition = (
|
||
condition: GekanatorQuestion['condition'],
|
||
): number | null => {
|
||
if (condition.type === 'original-month')
|
||
return condition.month
|
||
|
||
if (condition.type !== 'original-month-day')
|
||
return null
|
||
|
||
const month = Number (condition.monthDay.split ('-')[0])
|
||
return Number.isInteger (month) ? month : null
|
||
}
|
||
|
||
|
||
const isTitleLengthContradiction = (
|
||
candidate: GekanatorQuestion['condition'],
|
||
previous: GekanatorQuestion['condition'],
|
||
answer: GekanatorAnswerValue,
|
||
): boolean => {
|
||
const candidateLength = titleLengthMinimumForCondition (candidate)
|
||
const previousLength = titleLengthMinimumForCondition (previous)
|
||
if (candidateLength === null || previousLength === null)
|
||
return false
|
||
|
||
switch (answer)
|
||
{
|
||
case 'yes':
|
||
return candidateLength <= previousLength
|
||
case 'no':
|
||
return candidateLength >= previousLength
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
|
||
const isQuestionRedundantAfterAnswers = (
|
||
question: GekanatorQuestion,
|
||
answers: GekanatorAnswerLog[],
|
||
): boolean => answers.some (answer => {
|
||
const previous = answer.questionCondition
|
||
return previous !== undefined
|
||
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
|
||
})
|
||
|
||
|
||
const isMonthCrossMatch = (
|
||
candidate: GekanatorQuestion['condition'],
|
||
previous: GekanatorQuestion['condition'],
|
||
): boolean => {
|
||
const candidateMonth = monthForCondition (candidate)
|
||
const previousMonth = monthForCondition (previous)
|
||
if (candidateMonth === null || previousMonth === null)
|
||
return false
|
||
|
||
const sameType = candidate.type === previous.type
|
||
if (sameType)
|
||
return false
|
||
|
||
return candidateMonth === previousMonth
|
||
}
|
||
|
||
|
||
const isExclusiveContradiction = (
|
||
candidate: GekanatorQuestion['condition'],
|
||
previous: GekanatorQuestion['condition'],
|
||
): boolean => {
|
||
const candidateGroup = exclusiveConditionGroupFor (candidate)
|
||
const previousGroup = exclusiveConditionGroupFor (previous)
|
||
|
||
if (candidateGroup !== null && candidateGroup === previousGroup)
|
||
return !(sameConditionValue (candidate, previous))
|
||
|
||
const candidateMonth = monthForCondition (candidate)
|
||
const previousMonth = monthForCondition (previous)
|
||
if (candidateMonth !== null && previousMonth !== null)
|
||
return candidateMonth !== previousMonth
|
||
|
||
return false
|
||
}
|
||
|
||
|
||
const contradictionPenaltyFor = ({
|
||
question,
|
||
answers,
|
||
}: {
|
||
question: GekanatorQuestion
|
||
answers: GekanatorAnswerLog[]
|
||
}): number => {
|
||
return answers.reduce ((sum, answer) => {
|
||
const previous = answer.questionCondition
|
||
if (!(previous))
|
||
return sum
|
||
|
||
switch (answer.answer)
|
||
{
|
||
case 'yes':
|
||
return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0)
|
||
case 'partial':
|
||
return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
|
||
case 'no':
|
||
return sum + (
|
||
sameConditionValue (question.condition, previous)
|
||
|| isMonthCrossMatch (question.condition, previous)
|
||
? 40
|
||
: 0)
|
||
case 'probably_no':
|
||
return sum + (
|
||
sameConditionValue (question.condition, previous)
|
||
|| isMonthCrossMatch (question.condition, previous)
|
||
? 20
|
||
: 0)
|
||
default:
|
||
return sum
|
||
}
|
||
}, 0)
|
||
}
|
||
|
||
|
||
const chooseQuestion = ({
|
||
posts,
|
||
questions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
scores: Map<number, number>
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
gameSeed: string
|
||
}): GekanatorQuestion | null => {
|
||
const scoredPosts = posts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)
|
||
const maxScore = scoredPosts[0]?.score ?? 0
|
||
const weightedPosts = scoredPosts.map (item => ({
|
||
...item,
|
||
weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
|
||
const totalWeight =
|
||
weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||
const normalisedWeightedPosts =
|
||
weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight }))
|
||
|
||
const signatureFor = (
|
||
question: GekanatorQuestion,
|
||
candidates: { post: Post; score: number }[],
|
||
): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('')
|
||
|
||
const invertedSignature = (signature: string): string =>
|
||
signature.replace (/[01]/g, value => value === '1' ? '0' : '1')
|
||
|
||
const redundantSignatures = (
|
||
candidates: { post: Post; score: number }[],
|
||
): Set<string> => {
|
||
const signatures = new Set<string> ()
|
||
questions
|
||
.filter (question => askedIds.has (question.id))
|
||
.forEach (question => {
|
||
const signature = signatureFor (question, candidates)
|
||
signatures.add (signature)
|
||
signatures.add (invertedSignature (signature))
|
||
})
|
||
|
||
return signatures
|
||
}
|
||
|
||
const rank = (
|
||
questionsToRank: GekanatorQuestion[],
|
||
candidates: { post: Post; score: number }[],
|
||
weightedCandidates: { post: Post; score: number; weight: number }[],
|
||
) => {
|
||
const redundant = redundantSignatures (candidates)
|
||
const nonTagCount =
|
||
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
|
||
|
||
return questionsToRank
|
||
.map (question => {
|
||
if (isQuestionRedundantAfterAnswers (question, answers))
|
||
return null
|
||
|
||
const signature = signatureFor (question, candidates)
|
||
if (redundant.has (signature))
|
||
return null
|
||
|
||
const yes = signature.split ('').filter (value => value === '1').length
|
||
const no = candidates.length - yes
|
||
if (yes === 0 || no === 0)
|
||
return null
|
||
|
||
const yesWeight = weightedCandidates.reduce (
|
||
(sum, item) => sum + (question.test (item.post) ? item.weight : 0),
|
||
0)
|
||
const noWeight = 1 - yesWeight
|
||
if (yesWeight <= 0 || noWeight <= 0)
|
||
return null
|
||
|
||
const weightedSplitScore = Math.abs (.5 - yesWeight)
|
||
const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
|
||
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
|
||
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
|
||
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
|
||
const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
|
||
const sourceBonus = sourcePriorityOffset (question)
|
||
const priorityBonus = priorityWeightOffset (question)
|
||
|
||
return { question,
|
||
score: weightedSplitScore * 100
|
||
+ unweightedSplitScore * 8
|
||
+ tagPenalty
|
||
+ narrowPenalty
|
||
+ contradictionPenalty
|
||
+ sourceBonus
|
||
+ priorityBonus,
|
||
narrow: narrowPenalty > 0 }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
score: number
|
||
narrow: boolean } => item !== null && Number.isFinite (item.score))
|
||
.sort ((a, b) => a.score - b.score)
|
||
}
|
||
|
||
const unansweredQuestions =
|
||
questions.filter (question => !(askedIds.has (question.id)))
|
||
const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
|
||
const pool = (
|
||
ranked.some (item => !(item.narrow))
|
||
? ranked.filter (item => !(item.narrow))
|
||
: ranked)
|
||
.slice (0, 12)
|
||
|
||
if (pool.length === 0)
|
||
return null
|
||
|
||
const bestScore = pool[0]?.score ?? 0
|
||
const weightedPool = pool.map (item => ({
|
||
...item,
|
||
weight: Math.exp ((bestScore - item.score) / 1.8) }))
|
||
const totalPoolWeight =
|
||
weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||
const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${
|
||
weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|')
|
||
}`
|
||
const target = deterministicUnitFloat (seed) * totalPoolWeight
|
||
let cumulative = 0
|
||
|
||
for (const item of weightedPool)
|
||
{
|
||
cumulative += item.weight
|
||
if (target <= cumulative)
|
||
return item.question
|
||
}
|
||
|
||
return weightedPool[weightedPool.length - 1]?.question ?? null
|
||
}
|
||
|
||
|
||
const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
|
||
posts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)[0]?.post ?? null
|
||
|
||
|
||
const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
|
||
<div className="flex gap-3 items-center min-w-0">
|
||
<img
|
||
src={post.thumbnail || post.thumbnailBase || undefined}
|
||
alt={post.title || post.url}
|
||
className="w-16 h-16 rounded object-cover bg-yellow-100"/>
|
||
<div className="min-w-0">
|
||
<PrefetchLink
|
||
to={`/posts/${ post.id }`}
|
||
className="font-bold text-pink-700 dark:text-pink-200 break-words">
|
||
#{post.id} {post.title || post.url}
|
||
</PrefetchLink>
|
||
<div className="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-1">
|
||
{post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
|
||
|
||
const expectedAnswerFor = (
|
||
question: GekanatorQuestion | undefined,
|
||
correctPost: Post | null,
|
||
): GekanatorAnswerValue | null =>
|
||
expectedAnswerForQuestion (question, correctPost)
|
||
|
||
|
||
const GekanatorPage: FC = () => {
|
||
const storedGame = useMemo (loadStoredGame, [])
|
||
const queryClient = useQueryClient ()
|
||
const [gameSeed, setGameSeed] = useState (
|
||
storedGame?.gameSeed ?? createGameSeed ())
|
||
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
|
||
const [scores, setScores] = useState<Map<number, number>> (
|
||
() => new Map (storedGame?.scores ?? []))
|
||
const [answers, setAnswers] = useState<GekanatorAnswerLog[]> (
|
||
storedGame?.answers ?? [])
|
||
const [askedIds, setAskedIds] = useState<Set<string>> (
|
||
() => new Set (storedGame?.askedIds ?? []))
|
||
const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
|
||
() => new Set (storedGame?.softenedQuestionIds ?? []))
|
||
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
|
||
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
|
||
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
|
||
(storedGame?.askedQuestionBank?.length ?? 0) > 0
|
||
? []
|
||
: storedGame?.askedQuestionBankIds ?? [])
|
||
const [search, setSearch] = useState (storedGame?.search ?? '')
|
||
const [selectingCorrectPost, setSelectingCorrectPost] = useState (
|
||
storedGame?.selectingCorrectPost ?? false)
|
||
const [saved, setSaved] = useState (storedGame?.saved ?? false)
|
||
const [resultWon, setResultWon] = useState<boolean | null> (
|
||
storedGame?.resultWon ?? null)
|
||
const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (
|
||
() => new Set (storedGame?.rejectedPostIds ?? []))
|
||
const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (
|
||
storedGame?.lastGuessQuestionCount ?? 0)
|
||
const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (
|
||
storedGame?.lastRejectedGuessId ?? null)
|
||
const [activeGuessId, setActiveGuessId] = useState<number | null> (
|
||
storedGame?.activeGuessId ?? null)
|
||
const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
|
||
storedGame?.reviewGuessedPostId ?? null)
|
||
const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (
|
||
storedGame?.reviewCorrectPostId ?? null)
|
||
const [savedGameId, setSavedGameId] = useState<number | null> (
|
||
storedGame?.savedGameId ?? null)
|
||
const [questionSuggestion, setQuestionSuggestion] = useState (
|
||
storedGame?.questionSuggestion ?? '')
|
||
const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
|
||
useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
|
||
const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
|
||
storedGame?.questionSuggestionCount ?? 0)
|
||
const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
|
||
storedGame?.extraQuestions ?? [])
|
||
const [extraQuestionAnswers, setExtraQuestionAnswers] =
|
||
useState<Record<string, GekanatorAnswerValue>> (
|
||
storedGame?.extraQuestionAnswers ?? { })
|
||
const [extraQuestionState, setExtraQuestionState] = useState<
|
||
'idle' | 'loading' | 'ready' | 'empty' | 'saved'
|
||
> (storedGame?.extraQuestionState ?? 'idle')
|
||
const [history, setHistory] = useState<GameSnapshot[]> ([])
|
||
|
||
const { data: posts = [], isLoading, error } = useQuery ({
|
||
queryKey: gekanatorKeys.posts (),
|
||
queryFn: fetchGekanatorPosts,
|
||
refetchOnWindowFocus: false })
|
||
const {
|
||
data: acceptedQuestions = [],
|
||
isFetched: acceptedQuestionsFetched,
|
||
isLoading: acceptedQuestionsLoading,
|
||
error: acceptedQuestionsError
|
||
} = useQuery ({
|
||
queryKey: gekanatorKeys.questions (),
|
||
queryFn: fetchGekanatorQuestions,
|
||
select: questions => questions.map (restoreGekanatorQuestion),
|
||
refetchOnWindowFocus: false })
|
||
|
||
useEffect (() => {
|
||
if (
|
||
posts.length === 0
|
||
|| storedAskedQuestionBankIds.length === 0
|
||
|| !(acceptedQuestionsFetched)
|
||
)
|
||
return
|
||
|
||
const questionById = new Map (
|
||
mergeQuestions ([
|
||
...buildGekanatorQuestions (posts),
|
||
...acceptedQuestions])
|
||
.map (question => [question.id, question]))
|
||
setAskedQuestionBank (
|
||
storedAskedQuestionBankIds
|
||
.map (questionId => questionById.get (questionId))
|
||
.filter ((question): question is GekanatorQuestion => question !== undefined))
|
||
setStoredAskedQuestionBankIds ([])
|
||
}, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched])
|
||
|
||
useEffect (() => {
|
||
if (!(isStoredPhase (phase)) && answers.length === 0)
|
||
{
|
||
clearStoredGame ()
|
||
return
|
||
}
|
||
|
||
const stored: StoredGekanatorGame = {
|
||
phase,
|
||
scores: [...scores.entries ()],
|
||
answers,
|
||
askedIds: [...askedIds],
|
||
softenedQuestionIds: [...softenedQuestionIds],
|
||
askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
|
||
askedQuestionBankIds: storedAskedQuestionBankIds,
|
||
search,
|
||
selectingCorrectPost,
|
||
saved,
|
||
resultWon,
|
||
rejectedPostIds: [...rejectedPostIds],
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
activeGuessId,
|
||
reviewGuessedPostId,
|
||
reviewCorrectPostId,
|
||
savedGameId,
|
||
gameSeed,
|
||
questionSuggestion,
|
||
questionSuggestionAnswer,
|
||
questionSuggestionCount,
|
||
extraQuestions,
|
||
extraQuestionAnswers,
|
||
extraQuestionState }
|
||
|
||
try
|
||
{
|
||
sessionStorage.setItem (gameStorageKey, JSON.stringify (stored))
|
||
}
|
||
catch
|
||
{
|
||
return
|
||
}
|
||
}, [
|
||
phase,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
softenedQuestionIds,
|
||
askedQuestionBank,
|
||
storedAskedQuestionBankIds,
|
||
search,
|
||
selectingCorrectPost,
|
||
saved,
|
||
resultWon,
|
||
rejectedPostIds,
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
activeGuessId,
|
||
reviewGuessedPostId,
|
||
reviewCorrectPostId,
|
||
savedGameId,
|
||
gameSeed,
|
||
questionSuggestion,
|
||
questionSuggestionAnswer,
|
||
questionSuggestionCount,
|
||
extraQuestions,
|
||
extraQuestionAnswers,
|
||
extraQuestionState])
|
||
|
||
const eligiblePosts = useMemo (
|
||
() => candidatePostsFor ({
|
||
posts,
|
||
questions: askedQuestionBank,
|
||
answers,
|
||
softenedQuestionIds,
|
||
rejectedPostIds }),
|
||
[posts, askedQuestionBank, answers, softenedQuestionIds, rejectedPostIds])
|
||
const questions = useMemo (
|
||
() => mergeQuestions ([
|
||
...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
|
||
...acceptedQuestions]),
|
||
[acceptedQuestions, eligiblePosts, posts])
|
||
const scoringQuestions = useMemo (() => {
|
||
return mergeQuestions ([...questions, ...askedQuestionBank])
|
||
}, [questions, askedQuestionBank])
|
||
const scoringQuestionById = useMemo (
|
||
() => new Map (scoringQuestions.map (question => [question.id, question])),
|
||
[scoringQuestions])
|
||
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 }))
|
||
.sort ((a, b) => b.score - a.score)
|
||
.slice (0, 3),
|
||
[eligiblePosts, scores])
|
||
const currentQuestion = chooseQuestion ({
|
||
posts: questionPosts,
|
||
questions: scoringQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed })
|
||
const answerPreviews = useMemo (
|
||
() => currentQuestion
|
||
? answerOptions.map (option => previewAnswer ({
|
||
posts: eligiblePosts,
|
||
scores,
|
||
question: currentQuestion,
|
||
answer: option.value }))
|
||
: [],
|
||
[currentQuestion, eligiblePosts, scores])
|
||
const guessablePosts =
|
||
eligiblePosts.length > 0
|
||
? eligiblePosts
|
||
: nonRejectedPosts
|
||
const guess = bestPost (guessablePosts, scores)
|
||
const displayedGuess =
|
||
posts.find (post => post.id === activeGuessId) ?? guess
|
||
const reviewGuessedPost =
|
||
posts.find (post => post.id === reviewGuessedPostId) ?? null
|
||
const reviewCorrectPost =
|
||
posts.find (post => post.id === reviewCorrectPostId) ?? null
|
||
const saveMutation = useMutation ({
|
||
mutationFn: saveGekanatorGame,
|
||
onSuccess: (data, variables) => {
|
||
setSaved (true)
|
||
setSavedGameId (data.id)
|
||
setResultWon (variables.guessedPostId === variables.correctPostId)
|
||
}})
|
||
const questionSuggestionMutation = useMutation ({
|
||
mutationFn: saveGekanatorQuestionSuggestion,
|
||
onSuccess: async data => {
|
||
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
|
||
setQuestionSuggestionCount (data.count)
|
||
setQuestionSuggestion ('')
|
||
setQuestionSuggestionAnswer ('yes')
|
||
}})
|
||
const extraQuestionAnswersMutation = useMutation ({
|
||
mutationFn: saveGekanatorExtraQuestionAnswers,
|
||
onSuccess: async () => {
|
||
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
|
||
setExtraQuestionState ('saved')
|
||
setPhase ('learned')
|
||
}})
|
||
|
||
const resetExtraQuestionState = () => {
|
||
const next = resettableExtraQuestionState ()
|
||
setExtraQuestions (next.extraQuestions)
|
||
setExtraQuestionAnswers (next.extraQuestionAnswers)
|
||
setExtraQuestionState (next.extraQuestionState)
|
||
extraQuestionAnswersMutation.reset ()
|
||
}
|
||
|
||
const reset = () => {
|
||
clearStoredGame ()
|
||
saveMutation.reset ()
|
||
questionSuggestionMutation.reset ()
|
||
setPhase ('intro')
|
||
setScores (new Map ())
|
||
setAnswers ([])
|
||
setAskedIds (new Set ())
|
||
setSoftenedQuestionIds (new Set ())
|
||
setAskedQuestionBank ([])
|
||
setSearch ('')
|
||
setSelectingCorrectPost (false)
|
||
setSaved (false)
|
||
setResultWon (null)
|
||
setRejectedPostIds (new Set ())
|
||
setLastGuessQuestionCount (0)
|
||
setLastRejectedGuessId (null)
|
||
setActiveGuessId (null)
|
||
setReviewGuessedPostId (null)
|
||
setReviewCorrectPostId (null)
|
||
setSavedGameId (null)
|
||
setGameSeed (createGameSeed ())
|
||
setQuestionSuggestion ('')
|
||
setQuestionSuggestionAnswer ('yes')
|
||
setQuestionSuggestionCount (0)
|
||
resetExtraQuestionState ()
|
||
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),
|
||
...acceptedQuestions,
|
||
...nextAskedQuestionBank])
|
||
|
||
while (
|
||
recoveredEligiblePosts.length === 0
|
||
|| (
|
||
recoveredEligiblePosts.length !== 1
|
||
&& !(chooseQuestion ({
|
||
posts: recoveredEligiblePosts,
|
||
questions: recoveredScoringQuestions,
|
||
scores: recoveredScores,
|
||
answers: nextAnswers,
|
||
askedIds: nextAskedIds,
|
||
gameSeed })))
|
||
)
|
||
{
|
||
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),
|
||
...acceptedQuestions,
|
||
...nextAskedQuestionBank])
|
||
}
|
||
|
||
return {
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
scores: recoveredScores,
|
||
eligiblePosts: recoveredEligiblePosts,
|
||
scoringQuestions: recoveredScoringQuestions }
|
||
}
|
||
|
||
const answer = (value: GekanatorAnswerValue) => {
|
||
if (!(currentQuestion))
|
||
{
|
||
setActiveGuessId (guess?.id ?? null)
|
||
setPhase ('guess')
|
||
return
|
||
}
|
||
|
||
setHistory ([...history, {
|
||
phase,
|
||
scores: new Map (scores),
|
||
answers: [...answers],
|
||
askedIds: new Set (askedIds),
|
||
softenedQuestionIds: new Set (softenedQuestionIds),
|
||
askedQuestionBank: [...askedQuestionBank],
|
||
search,
|
||
selectingCorrectPost,
|
||
rejectedPostIds: new Set (rejectedPostIds),
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
activeGuessId,
|
||
reviewGuessedPostId,
|
||
reviewCorrectPostId }])
|
||
const nextAnswers = [...answers, {
|
||
questionId: currentQuestion.id,
|
||
questionText: currentQuestion.text,
|
||
questionCondition: currentQuestion.condition,
|
||
answer: value,
|
||
originalAnswer: value }]
|
||
const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
|
||
const nextAskedQuestionBank = [
|
||
...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
|
||
currentQuestion]
|
||
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)
|
||
setSoftenedQuestionIds (nextSoftenedQuestionIds)
|
||
setAskedQuestionBank (nextAskedQuestionBank)
|
||
setAnswers (nextAnswers)
|
||
|
||
const nextGuessablePosts =
|
||
nextEligiblePosts.length > 0
|
||
? nextEligiblePosts
|
||
: nonRejectedPosts
|
||
const nextGuess = bestPost (nextGuessablePosts, nextScores)
|
||
const nextQuestionCount = answers.length + 1
|
||
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
|
||
|| canGuessByQuestionCount
|
||
|| canGuessEarlyByConfidence
|
||
if (shouldGuess)
|
||
{
|
||
setActiveGuessId (nextGuess?.id ?? null)
|
||
setLastGuessQuestionCount (nextQuestionCount)
|
||
setPhase ('guess')
|
||
}
|
||
}
|
||
|
||
const finishGame = (correctPostId: number) => {
|
||
const guessedPostId =
|
||
phase === 'end' || phase === 'review'
|
||
? reviewGuessedPostId
|
||
: phase === 'continue'
|
||
? lastRejectedGuessId ?? displayedGuess?.id
|
||
: displayedGuess?.id ?? lastRejectedGuessId
|
||
if (!(guessedPostId))
|
||
return
|
||
|
||
saveMutation.reset ()
|
||
questionSuggestionMutation.reset ()
|
||
resetExtraQuestionState ()
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
setReviewGuessedPostId (guessedPostId)
|
||
setReviewCorrectPostId (correctPostId)
|
||
setSearch ('')
|
||
setSelectingCorrectPost (false)
|
||
setPhase ('end')
|
||
}
|
||
|
||
const startReview = () => {
|
||
if (reviewGuessedPostId === null || reviewCorrectPostId === null)
|
||
return
|
||
|
||
saveMutation.reset ()
|
||
questionSuggestionMutation.reset ()
|
||
resetExtraQuestionState ()
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
setSelectingCorrectPost (false)
|
||
setSearch ('')
|
||
setPhase ('review')
|
||
}
|
||
|
||
const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
|
||
if (
|
||
reviewGuessedPostId === null
|
||
|| reviewCorrectPostId === null
|
||
|| saveMutation.isPending
|
||
)
|
||
return
|
||
|
||
if (savedGameId !== null)
|
||
{
|
||
onSuccess (savedGameId)
|
||
return
|
||
}
|
||
|
||
saveMutation.mutate ({
|
||
guessedPostId: reviewGuessedPostId,
|
||
correctPostId: reviewCorrectPostId,
|
||
answers },
|
||
{ onSuccess: data => onSuccess (data.id) })
|
||
}
|
||
|
||
const saveAndReset = () => {
|
||
saveReviewedResult (reset)
|
||
}
|
||
|
||
const saveAndLearn = () => {
|
||
resetExtraQuestionState ()
|
||
saveReviewedResult (() => setPhase ('learned'))
|
||
}
|
||
|
||
const restartFromQuestionSuggestion = () => {
|
||
if (savedGameId !== null)
|
||
{
|
||
reset ()
|
||
return
|
||
}
|
||
|
||
saveReviewedResult (reset)
|
||
}
|
||
|
||
const submitQuestionSuggestion = () => {
|
||
const questionText = questionSuggestion.trim ()
|
||
if (
|
||
!(questionText)
|
||
|| questionSuggestionMutation.isPending
|
||
|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||
)
|
||
return
|
||
|
||
saveReviewedResult (gekanatorGameId => {
|
||
questionSuggestionMutation.mutate ({
|
||
gekanatorGameId,
|
||
questionText,
|
||
answer: questionSuggestionAnswer })
|
||
})
|
||
}
|
||
|
||
const rejectGuess = () => {
|
||
if (!(displayedGuess))
|
||
return
|
||
|
||
setLastRejectedGuessId (displayedGuess.id)
|
||
if (answers.length >= hardMaxQuestions)
|
||
{
|
||
setSelectingCorrectPost (true)
|
||
return
|
||
}
|
||
|
||
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
||
setActiveGuessId (null)
|
||
setSearch ('')
|
||
setSelectingCorrectPost (false)
|
||
setLastGuessQuestionCount (answers.length)
|
||
setPhase ('continue')
|
||
}
|
||
|
||
const undoAnswer = () => {
|
||
const snapshot = history[history.length - 1]
|
||
if (!(snapshot) || saved)
|
||
return
|
||
|
||
setPhase (snapshot.phase)
|
||
setScores (snapshot.scores)
|
||
setAnswers (snapshot.answers)
|
||
setAskedIds (snapshot.askedIds)
|
||
setSoftenedQuestionIds (snapshot.softenedQuestionIds)
|
||
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 continueGame = () => {
|
||
setSearch ('')
|
||
setSelectingCorrectPost (false)
|
||
|
||
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,
|
||
answers,
|
||
askedIds,
|
||
gameSeed })
|
||
|
||
if (nextQuestion)
|
||
{
|
||
setPhase ('question')
|
||
return
|
||
}
|
||
|
||
setActiveGuessId (guess?.id ?? null)
|
||
setPhase ('guess')
|
||
}
|
||
|
||
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
resetExtraQuestionState ()
|
||
setAnswers (answers.map ((answer, i) =>
|
||
i === index ? { ...answer, answer: value } : answer))
|
||
}
|
||
|
||
const selectCorrectPost = (post: Post) => {
|
||
if (phase === 'review')
|
||
{
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
resetExtraQuestionState ()
|
||
setReviewCorrectPostId (post.id)
|
||
setSelectingCorrectPost (false)
|
||
setSearch ('')
|
||
return
|
||
}
|
||
|
||
finishGame (post.id)
|
||
}
|
||
|
||
const filteredPosts = posts
|
||
.filter (post => {
|
||
const needle = search.trim ().toLowerCase ()
|
||
if (!(needle))
|
||
return false
|
||
if (/^\d+$/.test (needle) && post.id === Number (needle))
|
||
return true
|
||
|
||
return [post.title, post.url, ...post.tags.map (tag => tag.name)]
|
||
.filter ((value): value is string => Boolean (value))
|
||
.some (value => value.toLowerCase ().includes (needle))
|
||
})
|
||
.sort ((a, b) => {
|
||
const id = Number (search.trim ())
|
||
if (Number.isFinite (id))
|
||
return Number (b.id === id) - Number (a.id === id)
|
||
|
||
return 0
|
||
})
|
||
.slice (0, 20)
|
||
|
||
const loadExtraQuestions = async (gameId: number) => {
|
||
extraQuestionAnswersMutation.reset ()
|
||
setExtraQuestionState ('loading')
|
||
setExtraQuestions ([])
|
||
setExtraQuestionAnswers ({ })
|
||
setPhase ('extra_questions')
|
||
|
||
try
|
||
{
|
||
const questions = await queryClient.fetchQuery ({
|
||
queryKey: gekanatorKeys.extraQuestions (gameId),
|
||
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
|
||
setExtraQuestions (questions)
|
||
setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
|
||
}
|
||
catch
|
||
{
|
||
setExtraQuestionState ('empty')
|
||
}
|
||
}
|
||
|
||
const startExtraQuestions = () => {
|
||
if (reviewCorrectPostId === null || saveMutation.isPending)
|
||
return
|
||
|
||
saveReviewedResult (gameId => {
|
||
void loadExtraQuestions (gameId)
|
||
})
|
||
}
|
||
|
||
const answerExtraQuestion = (
|
||
questionId: number,
|
||
value: GekanatorAnswerValue,
|
||
) => {
|
||
setExtraQuestionAnswers ({
|
||
...extraQuestionAnswers,
|
||
[String (questionId)]: value })
|
||
}
|
||
|
||
const saveExtraQuestions = () => {
|
||
if (
|
||
savedGameId === null
|
||
|| extraQuestionAnswersMutation.isPending
|
||
|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)]))
|
||
)
|
||
return
|
||
|
||
extraQuestionAnswersMutation.mutate ({
|
||
gameId: savedGameId,
|
||
answers: extraQuestions.map (question => ({
|
||
questionId: question.id,
|
||
answer: extraQuestionAnswers[String (question.id)] })) })
|
||
}
|
||
|
||
const dialogue =
|
||
phase === 'learned' && resultWon
|
||
? <>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||
: <>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>.質問から投稿を何でも当ててみせるよ</>
|
||
const introLoading = isLoading || acceptedQuestionsLoading
|
||
const readyToStart =
|
||
!(introLoading)
|
||
&& acceptedQuestionsFetched
|
||
&& posts.length > 0
|
||
&& !(error)
|
||
&& !(acceptedQuestionsError)
|
||
|
||
return (
|
||
<MainArea className="bg-yellow-50 dark:bg-red-975">
|
||
<Helmet>
|
||
<meta name="robots" content="noindex"/>
|
||
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
<div className="mx-auto max-w-4xl space-y-6">
|
||
<header className="space-y-2">
|
||
<p className="text-sm text-pink-700 dark:text-pink-200">おたのしみ</p>
|
||
<h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
|
||
グカネータ
|
||
</h1>
|
||
</header>
|
||
|
||
<section className="rounded-lg border border-yellow-300 bg-white p-4
|
||
shadow-sm dark:border-red-800 dark:bg-red-950">
|
||
<div className="flex gap-4">
|
||
<div className="grid h-24 w-24 shrink-0 place-items-center rounded-lg
|
||
bg-yellow-200 text-center text-sm font-bold
|
||
text-pink-700 dark:bg-red-900 dark:text-pink-100">
|
||
洗澡鹿
|
||
</div>
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
<p className="text-lg font-bold">
|
||
{dialogue}
|
||
</p>
|
||
|
||
{introLoading && (
|
||
<p>
|
||
{phase === 'intro'
|
||
? '投稿を読み込んでゐます...'
|
||
: '前回のグカネータ状態を復元してゐます...'}
|
||
</p>)}
|
||
{(Boolean (error) || Boolean (acceptedQuestionsError))
|
||
&& <p>グカネータの質問データを読み込めませんでした.</p>}
|
||
|
||
{phase === 'intro' && readyToStart && (
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={() => setPhase ('question')}>
|
||
はじめる
|
||
</button>)}
|
||
|
||
{phase === 'question' && currentQuestion && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500">
|
||
質問 {answers.length + 1}
|
||
</p>
|
||
<p className="text-xl font-bold">{currentQuestion.text}</p>
|
||
</div>
|
||
<div className="rounded border border-yellow-100 px-3 py-2
|
||
text-sm dark:border-red-900">
|
||
<div className="font-bold">現在候補: {eligiblePosts.length} 件</div>
|
||
{topScoredPosts.length > 0 && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||
{topScoredPosts.map (item => (
|
||
<span key={item.post.id}>
|
||
#{item.post.id}: score {item.score.toFixed (1)}
|
||
</span>))}
|
||
</div>)}
|
||
</div>
|
||
{answerPreviews.length > 0 && (
|
||
<div className="grid gap-2 text-sm md:grid-cols-2">
|
||
{answerOptions.map (option => {
|
||
const preview =
|
||
answerPreviews.find (item => item.answer === option.value)
|
||
return (
|
||
<div
|
||
key={option.value}
|
||
className="rounded border border-yellow-100 px-3 py-2
|
||
dark:border-red-900">
|
||
<span className="font-bold">{option.label}</span>
|
||
{' '}
|
||
<span className="text-neutral-600 dark:text-neutral-300">
|
||
なら候補 {preview ? preview.candidateCount : 0} 件
|
||
</span>
|
||
</div>)
|
||
})}
|
||
</div>)}
|
||
<div className="flex flex-wrap gap-2">
|
||
{answerOptions.map (option => (
|
||
<button
|
||
key={option.value}
|
||
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={() => answer (option.value)}>
|
||
{option.label}
|
||
</button>))}
|
||
{history.length > 0 && (
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-3 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
onClick={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
</div>)}
|
||
|
||
{!(isLoading) && phase === 'question' && !(currentQuestion) && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">
|
||
もう十分わかった。
|
||
</p>
|
||
<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 && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">これを想像してゐたね?</p>
|
||
<PostMiniCard post={displayedGuess}/>
|
||
<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"
|
||
onClick={() => {
|
||
if (displayedGuess)
|
||
finishGame (displayedGuess.id)
|
||
}}>
|
||
当たり
|
||
</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={rejectGuess}>
|
||
違ふ
|
||
</button>
|
||
{history.length > 0 && (
|
||
<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={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
{saveMutation.isError && (
|
||
<p className="text-sm text-red-600">
|
||
記録できませんでした。通信状態を確認してもう一度試して。
|
||
</p>)}
|
||
</div>)}
|
||
|
||
{phase === 'continue' && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">続けますか?</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"
|
||
onClick={continueGame}>
|
||
はい
|
||
</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={() => setSelectingCorrectPost (true)}>
|
||
いいえ
|
||
</button>
|
||
{history.length > 0 && (
|
||
<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={undoAnswer}>
|
||
戻る
|
||
</button>)}
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'end' && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500">ゲーム終了</p>
|
||
<p className="text-xl font-bold">グカカカカwwwww</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>
|
||
|
||
{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}
|
||
onClick={saveAndReset}>
|
||
もう一度
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
disabled={reviewCorrectPostId === null || saveMutation.isPending}
|
||
onClick={startReview}>
|
||
修正
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900"
|
||
disabled={saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending}
|
||
onClick={() => setPhase ('question_suggestion')}>
|
||
質問を追加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900 disabled:opacity-50"
|
||
disabled={reviewCorrectPostId === null
|
||
|| saveMutation.isPending
|
||
|| extraQuestionState === 'loading'
|
||
|| extraQuestionAnswersMutation.isPending}
|
||
onClick={startExtraQuestions}>
|
||
追加で質問に答へる
|
||
</button>
|
||
</div>
|
||
</div>)}
|
||
|
||
{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) => {
|
||
const expectedAnswer = expectedAnswerFor (
|
||
scoringQuestionById.get (answer.questionId),
|
||
reviewCorrectPost)
|
||
|
||
return (
|
||
<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>
|
||
<div className="mt-2 grid gap-1 text-sm md:grid-cols-3">
|
||
<div>
|
||
<span className="text-neutral-500">グカネータ判定: </span>
|
||
{expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
|
||
</div>
|
||
<div>
|
||
<span className="text-neutral-500">実際の回答: </span>
|
||
{answerLabelFor (answer.originalAnswer)}
|
||
</div>
|
||
<label className="block">
|
||
<span className="text-neutral-500">修正後: </span>
|
||
<select
|
||
value={answer.answer}
|
||
className="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>
|
||
</label>
|
||
</div>
|
||
</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
|
||
|| questionSuggestionMutation.isPending
|
||
}
|
||
onClick={saveAndLearn}>
|
||
完了
|
||
</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 ('end')}>
|
||
戻る
|
||
</button>
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'question_suggestion' && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500">質問追加</p>
|
||
<p className="text-xl font-bold">どんな質問なら見分けられさう?</p>
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
|
||
</p>
|
||
</div>
|
||
<label className="block space-y-2">
|
||
<span className="font-bold">質問候補</span>
|
||
<textarea
|
||
value={questionSuggestion}
|
||
onChange={ev => setQuestionSuggestion (ev.target.value)}
|
||
className="min-h-24 w-full rounded border border-yellow-300
|
||
bg-white px-3 py-2 dark:border-red-700
|
||
dark:bg-red-950"/>
|
||
</label>
|
||
<label className="block space-y-2">
|
||
<span className="font-bold">この正解投稿に対する答え</span>
|
||
<select
|
||
value={questionSuggestionAnswer}
|
||
className="rounded border border-yellow-300 bg-white px-2 py-1
|
||
dark:border-red-700 dark:bg-red-950"
|
||
onChange={ev =>
|
||
setQuestionSuggestionAnswer (
|
||
ev.target.value as GekanatorAnswerValue)}>
|
||
{answerOptions.map (option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>))}
|
||
</select>
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-4 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
disabled={saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending}
|
||
onClick={() => setPhase ('end')}>
|
||
戻る
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded border border-yellow-300 px-4 py-2
|
||
hover:bg-yellow-100 dark:border-red-700
|
||
dark:hover:bg-red-900 disabled:opacity-50"
|
||
disabled={
|
||
questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||
|| reviewCorrectPostId === null
|
||
|| questionSuggestion.trim () === ''
|
||
|| saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending
|
||
}
|
||
onClick={submitQuestionSuggestion}>
|
||
追加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500 disabled:opacity-50"
|
||
disabled={saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending}
|
||
onClick={restartFromQuestionSuggestion}>
|
||
もう一度
|
||
</button>
|
||
</div>
|
||
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
このゲームでは質問候補をこれ以上追加できません。
|
||
</p>)}
|
||
{(saveMutation.isError || questionSuggestionMutation.isError) && (
|
||
<p className="text-sm text-red-600">
|
||
記録できませんでした。通信状態を確認してもう一度試して。
|
||
</p>)}
|
||
</div>)}
|
||
|
||
{phase === 'extra_questions' && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500">追加学習</p>
|
||
<p className="text-xl font-bold">追加で 2 問まで答へてください。</p>
|
||
</div>
|
||
|
||
{extraQuestionState === 'loading' && (
|
||
<p>追加質問を読み込んでゐます...</p>)}
|
||
|
||
{extraQuestionState === 'empty' && (
|
||
<p>追加で学習できる質問はありませんでした。</p>)}
|
||
|
||
{extraQuestionState === 'ready' && (
|
||
<div className="space-y-3">
|
||
{extraQuestions.map ((question, index) => (
|
||
<div
|
||
key={question.id}
|
||
className="rounded border border-yellow-100 p-3
|
||
dark:border-red-900">
|
||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
追加質問 {index + 1}
|
||
</div>
|
||
<div className="font-bold">{question.text}</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{answerOptions.map (option => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className={cn (
|
||
'rounded border px-3 py-2',
|
||
extraQuestionAnswers[String (question.id)] === option.value
|
||
? 'border-pink-600 bg-pink-600 text-white'
|
||
: 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900')}
|
||
onClick={() => answerExtraQuestion (question.id, option.value)}>
|
||
{option.label}
|
||
</button>))}
|
||
</div>
|
||
</div>))}
|
||
</div>)}
|
||
|
||
{extraQuestionAnswersMutation.isError && (
|
||
<p className="text-sm text-red-600">
|
||
学習内容を保存できませんでした。通信状態を確認してもう一度試して。
|
||
</p>)}
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded border border-neutral-300 px-4 py-2
|
||
hover:bg-neutral-100 dark:border-neutral-700
|
||
dark:hover:bg-red-900"
|
||
disabled={extraQuestionAnswersMutation.isPending}
|
||
onClick={() => setPhase ('end')}>
|
||
戻る
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500 disabled:opacity-50"
|
||
disabled={
|
||
extraQuestionState !== 'ready'
|
||
|| extraQuestionAnswersMutation.isPending
|
||
|| extraQuestions.some (
|
||
question => !(extraQuestionAnswers[String (question.id)]))
|
||
}
|
||
onClick={saveExtraQuestions}>
|
||
学習する
|
||
</button>
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'learned' && (
|
||
<div className="space-y-3">
|
||
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={reset}>
|
||
もう一回
|
||
</button>
|
||
</div>)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{['guess', 'continue', 'question', 'end', '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">
|
||
<span className="font-bold">正解の投稿</span>
|
||
<input
|
||
value={search}
|
||
onChange={ev => setSearch (ev.target.value)}
|
||
className="w-full rounded border border-yellow-300 bg-white px-3 py-2
|
||
dark:border-red-700 dark:bg-red-950"
|
||
placeholder="投稿 Id.・タイトル・URL・タグで検索"/>
|
||
</label>
|
||
<div className="mt-4 space-y-3">
|
||
{filteredPosts.map (post => (
|
||
<button
|
||
key={post.id}
|
||
type="button"
|
||
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={() => selectCorrectPost (post)}>
|
||
<PostMiniCard post={post}/>
|
||
</button>))}
|
||
{search.trim () && filteredPosts.length === 0 && '見つかりません.'}
|
||
{saveMutation.isError && (
|
||
<p className="text-sm text-red-600">
|
||
記録できませんでした。通信状態を確認してもう一度試して。
|
||
</p>)}
|
||
</div>
|
||
</section>)}
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
|
||
export default GekanatorPage
|