4983 行
150 KiB
TypeScript
4983 行
150 KiB
TypeScript
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useCallback, useEffect, useMemo, useRef, 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 { expectedAnswerForQuestion,
|
||
fetchGekanatorExtraQuestions,
|
||
fetchGekanatorQuestions,
|
||
fetchGekanatorPosts,
|
||
normalizeTitleLengthCondition,
|
||
questionIdForCondition,
|
||
restoreGekanatorQuestion,
|
||
saveGekanatorExtraQuestionAnswers,
|
||
saveGekanatorGame,
|
||
saveGekanatorQuestionSuggestion,
|
||
storeGekanatorQuestion,
|
||
titleLengthMinimumForCondition } from '@/lib/gekanator'
|
||
import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
|
||
import { isQuestionHardFilteredAfterAnswers,
|
||
monthForCondition } from '@/lib/gekanatorQuestionFilters'
|
||
import { gekanatorKeys } from '@/lib/queryKeys'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { GekanatorAnswerLog,
|
||
GekanatorAnswerValue,
|
||
GekanatorExtraQuestion,
|
||
GekanatorPerformanceMode,
|
||
GekanatorQuestionCondition,
|
||
GekanatorQuestionKind,
|
||
GekanatorQuestion,
|
||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||
import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery'
|
||
import type { Post, User } 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>
|
||
recoveredCandidatePosts: Map<number, number>
|
||
recoveryStepCount: number
|
||
askedQuestionBank: GekanatorQuestion[]
|
||
search: string
|
||
selectingCorrectPost: boolean
|
||
rejectedPostIds: Set<number>
|
||
lastGuessQuestionCount: number
|
||
lastRejectedGuessId: number | null
|
||
winningRunTargetId: number | null
|
||
winningRunStartAnswerCount: number | null
|
||
guessReason: GuessReason | null
|
||
activeGuessId: number | null
|
||
reviewGuessedPostId: number | null
|
||
reviewCorrectPostId: number | null }
|
||
|
||
type StoredGekanatorGame = {
|
||
phase: Phase
|
||
scores: [number, number][]
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: string[]
|
||
softenedQuestionIds: string[]
|
||
recoveredCandidatePosts?: RecoveredCandidatePost[]
|
||
recoveryStepCount?: number
|
||
askedQuestionBank?: StoredGekanatorQuestion[]
|
||
askedQuestionBankIds?: string[]
|
||
search: string
|
||
selectingCorrectPost: boolean
|
||
saved: boolean
|
||
resultWon: boolean | null
|
||
rejectedPostIds: number[]
|
||
lastGuessQuestionCount: number
|
||
lastRejectedGuessId: number | null
|
||
winningRunTargetId?: number | null
|
||
winningRunStartAnswerCount?: number | null
|
||
guessReason?: GuessReason | 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' }
|
||
|
||
type RecentGameSummary = {
|
||
correctPostId: number
|
||
firstQuestionId: string | null
|
||
savedAt: number }
|
||
|
||
type BackgroundMotionMode = 'on' | 'calm' | 'off'
|
||
|
||
type GuessReason =
|
||
| 'hard_max_questions'
|
||
| 'winning_run_finished'
|
||
| 'question_count_checkpoint'
|
||
| 'question_generation_stalled'
|
||
|
||
type QuestionMode =
|
||
| 'winning_run'
|
||
| 'normal'
|
||
| null
|
||
|
||
type MascotState =
|
||
| 'idle'
|
||
| 'thinking_far'
|
||
| 'thinking_mid'
|
||
| 'thinking_near'
|
||
| 'confident'
|
||
| 'celebrate'
|
||
| 'failed'
|
||
|
||
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 minQuestionsBeforeCertainGuess = 25
|
||
const hardMaxQuestions = 80
|
||
const winningRunQuestionLimit = 3
|
||
const softenedAnswerWeight = .35
|
||
const confidenceTemperature = 6
|
||
const gameStorageKey = 'gekanator:game:v1'
|
||
const recentGamesStorageKey = 'gekanator:recent-games:v1'
|
||
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
|
||
const performanceModeStorageKey = 'gekanator:performance-mode:v1'
|
||
const maxQuestionSuggestionsPerGame = 3
|
||
const maxStoredRecentGames = 12
|
||
const mascotAssetByState: Record<MascotState, string> = {
|
||
idle: '/gekanator/mascot-idle.png',
|
||
thinking_far: '/gekanator/mascot-thinking-far.png',
|
||
thinking_mid: '/gekanator/mascot-thinking-mid.png',
|
||
thinking_near: '/gekanator/mascot-thinking-near.png',
|
||
confident: '/gekanator/mascot-confident.png',
|
||
celebrate: '/gekanator/mascot-celebrate.png',
|
||
failed: '/gekanator/mascot-failed.png' }
|
||
const mascotAltByState: Record<MascotState, string> = {
|
||
idle: '待機する洗澡鹿',
|
||
thinking_far: '遠くを見つめる洗澡鹿',
|
||
thinking_mid: '考え込む洗澡鹿',
|
||
thinking_near: '見通しが立ってきた洗澡鹿',
|
||
confident: '見通した顔の洗澡鹿',
|
||
celebrate: 'ご満悦の洗澡鹿',
|
||
failed: 'しょんぼりした洗澡鹿' }
|
||
|
||
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),
|
||
questionMode: ((answer.questionMode === 'winning_run' || answer.questionMode === 'normal')
|
||
? answer.questionMode
|
||
: undefined),
|
||
questionCondition: (answer.questionCondition
|
||
? normalizeTitleLengthCondition (answer.questionCondition)
|
||
: undefined) })),
|
||
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
|
||
softenedQuestionIds: (game.softenedQuestionIds
|
||
.map (questionId => normalizeStoredQuestionId (questionId))),
|
||
recoveredCandidatePosts: game.recoveredCandidatePosts ?? [],
|
||
recoveryStepCount: game.recoveryStepCount ?? 0,
|
||
winningRunTargetId: game.winningRunTargetId ?? null,
|
||
winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
|
||
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)
|
||
{
|
||
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 loadRecentGames = (): RecentGameSummary[] => {
|
||
try
|
||
{
|
||
const raw = localStorage.getItem (recentGamesStorageKey)
|
||
if (!(raw))
|
||
return []
|
||
|
||
const parsed = JSON.parse (raw)
|
||
if (!(Array.isArray (parsed)))
|
||
return []
|
||
|
||
return parsed
|
||
.filter ((item): item is RecentGameSummary =>
|
||
typeof item === 'object'
|
||
&& item !== null
|
||
&& Number.isInteger ((item as RecentGameSummary).correctPostId)
|
||
&& (((item as RecentGameSummary).firstQuestionId === null)
|
||
|| typeof (item as RecentGameSummary).firstQuestionId === 'string')
|
||
&& Number.isFinite ((item as RecentGameSummary).savedAt))
|
||
.sort ((a, b) => b.savedAt - a.savedAt)
|
||
.slice (0, maxStoredRecentGames)
|
||
}
|
||
catch
|
||
{
|
||
return []
|
||
}
|
||
}
|
||
|
||
|
||
const storeRecentGameSummary = (
|
||
summary: RecentGameSummary,
|
||
): RecentGameSummary[] => {
|
||
const next = [
|
||
summary,
|
||
...loadRecentGames ().filter (item =>
|
||
item.savedAt !== summary.savedAt
|
||
&& !(
|
||
item.correctPostId === summary.correctPostId
|
||
&& item.firstQuestionId === summary.firstQuestionId))]
|
||
.slice (0, maxStoredRecentGames)
|
||
|
||
try
|
||
{
|
||
localStorage.setItem (recentGamesStorageKey, JSON.stringify (next))
|
||
}
|
||
catch
|
||
{
|
||
return next
|
||
}
|
||
|
||
return next
|
||
}
|
||
|
||
|
||
const loadBackgroundMotionMode = (
|
||
performanceMode?: GekanatorPerformanceMode,
|
||
): BackgroundMotionMode => {
|
||
const fallbackMode =
|
||
performanceMode === 'lite' ? 'off'
|
||
: performanceMode === 'normal' ? 'on'
|
||
: detectDefaultPerformanceMode () === 'lite' ? 'off'
|
||
: 'on'
|
||
try
|
||
{
|
||
const raw = localStorage.getItem (backgroundMotionStorageKey)
|
||
if (raw === 'off' || raw === 'calm' || raw === 'on')
|
||
return raw
|
||
|
||
return fallbackMode
|
||
}
|
||
catch
|
||
{
|
||
return fallbackMode
|
||
}
|
||
}
|
||
|
||
|
||
const detectDefaultPerformanceMode = (): GekanatorPerformanceMode => {
|
||
if (typeof window === 'undefined')
|
||
return 'normal'
|
||
|
||
const isMobileWidth =
|
||
typeof window.matchMedia === 'function'
|
||
? window.matchMedia ('(max-width: 767px)').matches
|
||
: window.innerWidth <= 767
|
||
const memory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory
|
||
if ((typeof memory === 'number' && memory <= 4) || isMobileWidth)
|
||
return 'lite'
|
||
|
||
return 'normal'
|
||
}
|
||
|
||
|
||
const loadPerformanceMode = (): GekanatorPerformanceMode => {
|
||
try
|
||
{
|
||
const raw = localStorage.getItem (performanceModeStorageKey)
|
||
if (raw === 'lite' || raw === 'normal')
|
||
return raw
|
||
}
|
||
catch
|
||
{
|
||
return detectDefaultPerformanceMode ()
|
||
}
|
||
|
||
return detectDefaultPerformanceMode ()
|
||
}
|
||
|
||
|
||
const resettableExtraQuestionState = (): {
|
||
extraQuestions: GekanatorExtraQuestion[]
|
||
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||
extraQuestionState: 'idle'
|
||
} => ({
|
||
extraQuestions: [],
|
||
extraQuestionAnswers: { },
|
||
extraQuestionState: 'idle' })
|
||
|
||
|
||
const recoveredCandidateMapFromStored = (
|
||
items: RecoveredCandidatePost[],
|
||
): Map<number, number> =>
|
||
new Map (items.map (item => [item.postId, item.answerCountAtRecovery]))
|
||
|
||
|
||
const storedRecoveredCandidatesFromMap = (
|
||
recoveredCandidatePosts: Map<number, number>,
|
||
): RecoveredCandidatePost[] =>
|
||
[...recoveredCandidatePosts.entries ()].map (([postId, answerCountAtRecovery]) => ({
|
||
postId,
|
||
answerCountAtRecovery }))
|
||
|
||
|
||
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 distributionEntropy = (weights: number[]): number =>
|
||
weights.reduce ((sum, weight) =>
|
||
weight <= 0
|
||
? sum
|
||
: sum - weight * Math.log2 (weight), 0)
|
||
|
||
|
||
const questionCategoryPenalty = (
|
||
question: GekanatorQuestion,
|
||
answerCount: number,
|
||
repeatPenalty: number,
|
||
): number => {
|
||
const earlyFactor = Math.max (0, (3 - answerCount) / 3)
|
||
const titleLengthPenalty =
|
||
titleLengthMinimumForCondition (question.condition) === null
|
||
? 0
|
||
: (answerCount === 0 ? 8 : 3.5) * earlyFactor
|
||
|
||
switch (question.kind)
|
||
{
|
||
case 'tag':
|
||
return -2.8 * earlyFactor + repeatPenalty
|
||
case 'post_similarity':
|
||
return -3.2 * earlyFactor + repeatPenalty
|
||
case 'title':
|
||
return 3.4 * earlyFactor + titleLengthPenalty + repeatPenalty
|
||
case 'source':
|
||
case 'original_date':
|
||
return 2.4 * earlyFactor + repeatPenalty
|
||
default:
|
||
return repeatPenalty
|
||
}
|
||
}
|
||
|
||
|
||
const relatedPostIdsOf = (post: Post): number[] => {
|
||
const siblingPosts = Object.values (post.siblingPosts ?? { }).flat ()
|
||
|
||
return [...new Set ([
|
||
...(post.related ?? []).map (related => related.id),
|
||
...(post.parentPosts ?? []).map (parent => parent.id),
|
||
...(post.childPosts ?? []).map (child => child.id),
|
||
...siblingPosts.map (sibling => sibling.id)])]
|
||
}
|
||
|
||
|
||
const userPriorWeightsFor = (
|
||
posts: Post[],
|
||
recentGames: RecentGameSummary[],
|
||
): Map<number, number> => {
|
||
const postById = new Map (posts.map (post => [post.id, post]))
|
||
const weights = new Map<number, number> ()
|
||
const addWeight = (postId: number, weight: number) => {
|
||
if (!(postById.has (postId)) || weight <= 0)
|
||
return
|
||
|
||
weights.set (postId, (weights.get (postId) ?? 0) + weight)
|
||
}
|
||
|
||
recentGames.slice (0, 6).forEach ((game, index) => {
|
||
const baseWeight = Math.max (.24, 1 - index * .18)
|
||
addWeight (game.correctPostId, baseWeight)
|
||
|
||
const correctPost = postById.get (game.correctPostId)
|
||
if (!(correctPost))
|
||
return
|
||
|
||
relatedPostIdsOf (correctPost).forEach (postId => addWeight (postId, baseWeight * .45))
|
||
})
|
||
|
||
return weights
|
||
}
|
||
|
||
|
||
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
|
||
}
|
||
|
||
|
||
type GekanatorMatchIndex = Map<string, Set<number>>
|
||
|
||
type GekanatorQuestionMaterialIndex = {
|
||
postById: Map<number, Post>
|
||
tagKeysByPostId: Map<number, string[]>
|
||
postIdsByTagKey: Map<string, Set<number>>
|
||
titleTermsByPostId: Map<number, string[]>
|
||
postIdsByTitleTerm: Map<string, Set<number>>
|
||
hostByPostId: Map<number, string | null>
|
||
postIdsByHost: Map<string, Set<number>>
|
||
originalYearByPostId: Map<number, number | null>
|
||
postIdsByOriginalYear: Map<number, Set<number>>
|
||
originalMonthByPostId: Map<number, number | null>
|
||
postIdsByOriginalMonth: Map<number, Set<number>>
|
||
originalMonthDayByPostId: Map<number, string | null>
|
||
postIdsByOriginalMonthDay: Map<string, Set<number>>
|
||
titleLengthByPostId: Map<number, number>
|
||
titleAsciiPostIds: Set<number>
|
||
titleLengthThresholdCache: Map<number, Set<number>>
|
||
}
|
||
|
||
const titleTermPattern =
|
||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu
|
||
|
||
|
||
const addPostIdToIndex = <K extends string | number> (
|
||
index: Map<K, Set<number>>,
|
||
key: K,
|
||
postId: number,
|
||
) => {
|
||
const current = index.get (key)
|
||
if (current)
|
||
{
|
||
current.add (postId)
|
||
return
|
||
}
|
||
|
||
index.set (key, new Set ([postId]))
|
||
}
|
||
|
||
|
||
const buildMaterialIndex = (
|
||
posts: Post[],
|
||
): GekanatorQuestionMaterialIndex => {
|
||
const postById = new Map<number, Post> ()
|
||
const tagKeysByPostId = new Map<number, string[]> ()
|
||
const postIdsByTagKey = new Map<string, Set<number>> ()
|
||
const titleTermsByPostId = new Map<number, string[]> ()
|
||
const postIdsByTitleTerm = new Map<string, Set<number>> ()
|
||
const hostByPostId = new Map<number, string | null> ()
|
||
const postIdsByHost = new Map<string, Set<number>> ()
|
||
const originalYearByPostId = new Map<number, number | null> ()
|
||
const postIdsByOriginalYear = new Map<number, Set<number>> ()
|
||
const originalMonthByPostId = new Map<number, number | null> ()
|
||
const postIdsByOriginalMonth = new Map<number, Set<number>> ()
|
||
const originalMonthDayByPostId = new Map<number, string | null> ()
|
||
const postIdsByOriginalMonthDay = new Map<string, Set<number>> ()
|
||
const titleLengthByPostId = new Map<number, number> ()
|
||
const titleAsciiPostIds = new Set<number> ()
|
||
|
||
posts.forEach (post => {
|
||
postById.set (post.id, post)
|
||
|
||
const tagKeys = post.tags
|
||
.filter (tag =>
|
||
tag.category !== 'meta'
|
||
&& !(tag.name.includes ('タグ希望'))
|
||
&& !(tag.name.includes ('bot操作')))
|
||
.map (tag => `${ tag.category }:${ tag.name }`)
|
||
tagKeysByPostId.set (post.id, tagKeys)
|
||
tagKeys.forEach (key => addPostIdToIndex (postIdsByTagKey, key, post.id))
|
||
|
||
const titleTerms = Array.from (
|
||
new Set ((post.title ?? '').match (titleTermPattern) ?? []))
|
||
titleTermsByPostId.set (post.id, titleTerms)
|
||
titleTerms.forEach (term => addPostIdToIndex (postIdsByTitleTerm, term, post.id))
|
||
|
||
const host = (() => {
|
||
try
|
||
{
|
||
return new URL (post.url).hostname.replace (/^www\./, '')
|
||
}
|
||
catch
|
||
{
|
||
return null
|
||
}
|
||
}) ()
|
||
hostByPostId.set (post.id, host)
|
||
if (host)
|
||
addPostIdToIndex (postIdsByHost, host, post.id)
|
||
|
||
const originalValue = post.originalCreatedFrom || post.originalCreatedBefore
|
||
const date =
|
||
originalValue
|
||
? new Date (originalValue)
|
||
: null
|
||
const validDate =
|
||
date && !(Number.isNaN (date.getTime ()))
|
||
? date
|
||
: null
|
||
const originalYear = validDate?.getFullYear () ?? null
|
||
const originalMonth =
|
||
validDate
|
||
? validDate.getMonth () + 1
|
||
: null
|
||
const originalMonthDay =
|
||
validDate
|
||
? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
|
||
: null
|
||
originalYearByPostId.set (post.id, originalYear)
|
||
originalMonthByPostId.set (post.id, originalMonth)
|
||
originalMonthDayByPostId.set (post.id, originalMonthDay)
|
||
if (originalYear !== null)
|
||
addPostIdToIndex (postIdsByOriginalYear, originalYear, post.id)
|
||
if (originalMonth !== null)
|
||
addPostIdToIndex (postIdsByOriginalMonth, originalMonth, post.id)
|
||
if (originalMonthDay !== null)
|
||
addPostIdToIndex (postIdsByOriginalMonthDay, originalMonthDay, post.id)
|
||
|
||
const titleLength = post.title?.length ?? 0
|
||
titleLengthByPostId.set (post.id, titleLength)
|
||
if (/[A-Za-z0-9]/.test (post.title ?? ''))
|
||
titleAsciiPostIds.add (post.id)
|
||
})
|
||
|
||
return {
|
||
postById,
|
||
tagKeysByPostId,
|
||
postIdsByTagKey,
|
||
titleTermsByPostId,
|
||
postIdsByTitleTerm,
|
||
hostByPostId,
|
||
postIdsByHost,
|
||
originalYearByPostId,
|
||
postIdsByOriginalYear,
|
||
originalMonthByPostId,
|
||
postIdsByOriginalMonth,
|
||
originalMonthDayByPostId,
|
||
postIdsByOriginalMonthDay,
|
||
titleLengthByPostId,
|
||
titleAsciiPostIds,
|
||
titleLengthThresholdCache: new Map<number, Set<number>> () }
|
||
}
|
||
|
||
const indexedQuestionTextForTag = (key: string): string => {
|
||
const [category, ...rest] = key.split (':')
|
||
const name = rest.join (':')
|
||
const label = category === 'nico' ? name.replace (/^nico:/, '') : name
|
||
|
||
switch (category)
|
||
{
|
||
case 'deerjikist':
|
||
return `ニジラーとして「${ label }」に関係している?`
|
||
case 'meme':
|
||
return `『${ label }』に関係しそう?`
|
||
case 'character':
|
||
return `「${ label }」というキャラクターが関係している?`
|
||
case 'material':
|
||
return `素材「${ label }」に関係している?`
|
||
case 'nico':
|
||
return `ニコニコに「${ label }」というタグがついている?`
|
||
default:
|
||
return `「${ label }」が含まれる?`
|
||
}
|
||
}
|
||
|
||
const matchingPostIdsForCondition = ({
|
||
condition,
|
||
materialIndex,
|
||
}: {
|
||
condition: GekanatorQuestionCondition
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
}): Set<number> | null => {
|
||
switch (condition.type)
|
||
{
|
||
case 'tag':
|
||
return materialIndex.postIdsByTagKey.get (condition.key) ?? new Set<number> ()
|
||
case 'source':
|
||
return materialIndex.postIdsByHost.get (condition.host) ?? new Set<number> ()
|
||
case 'original-year':
|
||
return materialIndex.postIdsByOriginalYear.get (condition.year) ?? new Set<number> ()
|
||
case 'original-month':
|
||
return materialIndex.postIdsByOriginalMonth.get (condition.month) ?? new Set<number> ()
|
||
case 'original-month-day':
|
||
return materialIndex.postIdsByOriginalMonthDay.get (condition.monthDay) ?? new Set<number> ()
|
||
case 'title-has-ascii':
|
||
return materialIndex.titleAsciiPostIds
|
||
case 'title-contains':
|
||
return materialIndex.postIdsByTitleTerm.get (condition.text) ?? new Set<number> ()
|
||
case 'title-length-at-least':
|
||
case 'title-length-greater-than': {
|
||
const threshold =
|
||
titleLengthMinimumForCondition (condition)
|
||
if (threshold === null)
|
||
return new Set<number> ()
|
||
|
||
const cached = materialIndex.titleLengthThresholdCache.get (threshold)
|
||
if (cached)
|
||
return cached
|
||
|
||
const matched = new Set<number> ()
|
||
materialIndex.titleLengthByPostId.forEach ((length, postId) => {
|
||
if (length >= threshold)
|
||
matched.add (postId)
|
||
})
|
||
materialIndex.titleLengthThresholdCache.set (threshold, matched)
|
||
return matched
|
||
}
|
||
case 'post-similarity':
|
||
return null
|
||
}
|
||
}
|
||
|
||
type QuestionMatchResolver = {
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
question: GekanatorQuestion
|
||
dynamicMatchIndex?: GekanatorMatchIndex
|
||
}
|
||
|
||
const buildGekanatorMatchIndex = (
|
||
posts: Post[],
|
||
questions: GekanatorQuestion[],
|
||
): GekanatorMatchIndex => new Map (
|
||
questions.map (question => [
|
||
question.id,
|
||
new Set (
|
||
posts
|
||
.filter (post => question.test (post))
|
||
.map (post => post.id))]))
|
||
|
||
const matchingPostIdsForQuestion = ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex,
|
||
}: QuestionMatchResolver): Set<number> => {
|
||
const byCondition = matchingPostIdsForCondition ({
|
||
condition: question.condition,
|
||
materialIndex })
|
||
if (byCondition !== null)
|
||
return byCondition
|
||
|
||
const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id)
|
||
if (matched)
|
||
return matched
|
||
|
||
const computed = new Set (
|
||
posts
|
||
.filter (post => question.test (post))
|
||
.map (post => post.id))
|
||
dynamicMatchIndex?.set (question.id, computed)
|
||
return computed
|
||
}
|
||
|
||
const matchingPostCountInIds = ({
|
||
candidateIds,
|
||
candidateIdSet,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex,
|
||
}: {
|
||
candidateIds: number[]
|
||
candidateIdSet?: Set<number>
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
question: GekanatorQuestion
|
||
dynamicMatchIndex?: GekanatorMatchIndex
|
||
}): number => {
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const ids = candidateIdSet ?? new Set (candidateIds)
|
||
let count = 0
|
||
|
||
if (matched.size < ids.size)
|
||
matched.forEach (postId => {
|
||
if (ids.has (postId))
|
||
++count
|
||
})
|
||
else
|
||
ids.forEach (postId => {
|
||
if (matched.has (postId))
|
||
++count
|
||
})
|
||
|
||
return count
|
||
}
|
||
|
||
const matchingWeightInCandidates = (
|
||
{ candidates,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex }: { candidates: { post: Post; weight: number }[]
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
question: GekanatorQuestion
|
||
dynamicMatchIndex?: GekanatorMatchIndex },
|
||
): number => {
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
|
||
return candidates.reduce ((sum, item) =>
|
||
sum + (matched.has (item.post.id) ? item.weight : 0), 0)
|
||
}
|
||
|
||
const signatureForCandidateIds = (
|
||
{ candidateIds,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex, }: { candidateIds: number[]
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
question: GekanatorQuestion
|
||
dynamicMatchIndex?: GekanatorMatchIndex },
|
||
): string => {
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
|
||
return candidateIds.map (postId => matched.has (postId) ? '1' : '0').join ('')
|
||
}
|
||
|
||
const postIdsForHardAnswer = (
|
||
{ candidateIds,
|
||
question,
|
||
answer,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
dynamicMatchIndex }: { candidateIds: number[]
|
||
question: GekanatorQuestion
|
||
answer: GekanatorAnswerValue
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
dynamicMatchIndex?: GekanatorMatchIndex },
|
||
): number[] => {
|
||
if (answer === 'unknown'
|
||
|| answer === 'partial'
|
||
|| answer === 'probably_no')
|
||
return candidateIds
|
||
|
||
if (answer === 'yes')
|
||
{
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
return candidateIds.filter (postId => matched.has (postId))
|
||
}
|
||
|
||
if (answer === 'no')
|
||
{
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
return candidateIds.filter (postId => !(matched.has (postId)))
|
||
}
|
||
|
||
return candidateIds
|
||
}
|
||
|
||
const buildIndexedQuestion = (
|
||
{ condition,
|
||
text,
|
||
kind,
|
||
priorityWeight,
|
||
materialIndex }: {
|
||
condition: Exclude<GekanatorQuestionCondition, { type: 'post-similarity' }>
|
||
text: string
|
||
kind: GekanatorQuestionKind
|
||
priorityWeight: number
|
||
materialIndex: GekanatorQuestionMaterialIndex },
|
||
): GekanatorQuestion => ({
|
||
id: questionIdForCondition (condition),
|
||
text,
|
||
kind,
|
||
condition,
|
||
source: 'default',
|
||
priorityWeight,
|
||
test: post =>
|
||
(matchingPostIdsForCondition ({
|
||
condition,
|
||
materialIndex }) ?? new Set<number> ()).has (post.id) })
|
||
|
||
const rankedEntriesForCounts = <T extends string | number> (
|
||
{ counts, total, cap }: { counts: Map<T, number>
|
||
total: number
|
||
cap: number },
|
||
): [T, number][] =>
|
||
([...counts.entries ()]
|
||
.filter (([, count]) => count > 0 && count < total)
|
||
.sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
|
||
.slice (0, cap))
|
||
|
||
const buildQuestionsForCandidateIds = (
|
||
{ candidateIds,
|
||
materialIndex,
|
||
performanceMode,
|
||
acceptedQuestions }: { candidateIds: number[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
performanceMode: GekanatorPerformanceMode
|
||
acceptedQuestions: GekanatorQuestion[] },
|
||
): GekanatorQuestion[] => {
|
||
const total = candidateIds.length
|
||
if (total === 0)
|
||
return acceptedQuestions
|
||
|
||
const tagCounts = new Map<string, number> ()
|
||
const hostCounts = new Map<string, number> ()
|
||
const yearCounts = new Map<number, number> ()
|
||
const monthCounts = new Map<number, number> ()
|
||
const monthDayCounts = new Map<string, number> ()
|
||
const titleTermCounts = new Map<string, number> ()
|
||
const titleLengths: number[] = []
|
||
let asciiCount = 0
|
||
|
||
candidateIds.forEach (postId => {
|
||
materialIndex.tagKeysByPostId.get (postId)?.forEach (key =>
|
||
tagCounts.set (key, (tagCounts.get (key) ?? 0) + 1))
|
||
const host = materialIndex.hostByPostId.get (postId)
|
||
if (host)
|
||
hostCounts.set (host, (hostCounts.get (host) ?? 0) + 1)
|
||
const year = materialIndex.originalYearByPostId.get (postId)
|
||
if (year !== null && year !== undefined)
|
||
yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1)
|
||
const month = materialIndex.originalMonthByPostId.get (postId)
|
||
if (month !== null && month !== undefined)
|
||
monthCounts.set (month, (monthCounts.get (month) ?? 0) + 1)
|
||
const monthDay = materialIndex.originalMonthDayByPostId.get (postId)
|
||
if (monthDay)
|
||
monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1)
|
||
if (performanceMode === 'normal')
|
||
materialIndex.titleTermsByPostId.get (postId)?.forEach (term =>
|
||
titleTermCounts.set (term, (titleTermCounts.get (term) ?? 0) + 1))
|
||
const titleLength = materialIndex.titleLengthByPostId.get (postId) ?? 0
|
||
titleLengths.push (titleLength)
|
||
if (materialIndex.titleAsciiPostIds.has (postId))
|
||
++asciiCount
|
||
})
|
||
|
||
const tagCap =
|
||
performanceMode === 'lite'
|
||
? total >= 80 ? 96 : 64
|
||
: total >= 120 ? 128 : 96
|
||
const titleTermCap =
|
||
performanceMode === 'lite'
|
||
? 0
|
||
: total >= 80 ? 10 : total >= 24 ? 14 : 20
|
||
const factCap = total >= 80 ? 8 : 12
|
||
const sortedLengths = [...titleLengths].sort ((a, b) => a - b)
|
||
const titleLengthMedian = sortedLengths[Math.floor (sortedLengths.length / 2)] ?? 0
|
||
|
||
const questions: GekanatorQuestion[] = []
|
||
|
||
rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap })
|
||
.forEach (([host]) => {
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'source', host },
|
||
text: `${ host } の投稿を思い浮かべている?`,
|
||
kind: 'source',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap })
|
||
.forEach (([year]) => {
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'original-year', year },
|
||
text: `オリジナルの投稿年は ${ year } 年?`,
|
||
kind: 'original_date',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: monthCounts, total, cap: factCap })
|
||
.forEach (([month]) => {
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'original-month', month },
|
||
text: `オリジナルの投稿月は ${ month } 月?`,
|
||
kind: 'original_date',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: monthDayCounts, total, cap: factCap })
|
||
.forEach (([monthDay]) => {
|
||
const [month, day] = String (monthDay).split ('-')
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'original-month-day', monthDay: String (monthDay) },
|
||
text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`,
|
||
kind: 'original_date',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
})
|
||
|
||
if (titleLengthMedian > 0)
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: {
|
||
type: 'title-length-at-least',
|
||
length: titleLengthMedian },
|
||
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
||
kind: 'title',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
|
||
if (asciiCount > 0 && asciiCount < total)
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'title-has-ascii' },
|
||
text: '題名に英数字が混じっている?',
|
||
kind: 'title',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
|
||
rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap })
|
||
.forEach (([key]) => {
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'tag', key },
|
||
text: indexedQuestionTextForTag (key),
|
||
kind: 'tag',
|
||
priorityWeight: 1,
|
||
materialIndex }))
|
||
})
|
||
|
||
if (performanceMode === 'normal')
|
||
rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap })
|
||
.filter (([term]) => String (term).length <= 24)
|
||
.forEach (([term]) => {
|
||
questions.push (buildIndexedQuestion ({
|
||
condition: { type: 'title-contains', text: String (term) },
|
||
text: `題名に「${ term }」が含まれる?`,
|
||
kind: 'title',
|
||
priorityWeight: .96,
|
||
materialIndex }))
|
||
})
|
||
|
||
return mergeQuestions ([...questions, ...acceptedQuestions])
|
||
}
|
||
|
||
const candidatePostsForState = ({
|
||
posts,
|
||
questionById,
|
||
materialIndex,
|
||
matchIndex,
|
||
answers,
|
||
softenedQuestionIds,
|
||
rejectedPostIds,
|
||
recoveredCandidatePosts,
|
||
}: {
|
||
posts: Post[]
|
||
questionById: Map<string, GekanatorQuestion>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
rejectedPostIds: Set<number>
|
||
recoveredCandidatePosts: Map<number, number>
|
||
}): Post[] => {
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
|
||
return posts.filter (post => {
|
||
if (rejectedPostIds.has (post.id))
|
||
return false
|
||
|
||
const answerCountAtRecovery = recoveredCandidatePosts.get (post.id)
|
||
|
||
return answers.every ((answer, index) => {
|
||
if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery)
|
||
return true
|
||
if (softenedQuestionIds.has (answer.questionId))
|
||
return true
|
||
if (!(answer.answer === 'yes' || answer.answer === 'no'))
|
||
return true
|
||
|
||
const question = questionById.get (answer.questionId)
|
||
const condition = answer.questionCondition ?? question?.condition
|
||
if (!(condition))
|
||
return true
|
||
|
||
const matched = question
|
||
? matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
: matchingPostIdsForCondition ({
|
||
condition,
|
||
materialIndex })
|
||
if (matched !== null)
|
||
return answer.answer === 'yes'
|
||
? matched.has (post.id)
|
||
: !(matched.has (post.id))
|
||
|
||
if (!(question))
|
||
return true
|
||
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
return expected === null || expected === 'unknown' || expected === answer.answer
|
||
})
|
||
})
|
||
}
|
||
|
||
const hasDiscriminatingHardSplitForQuestion = ({
|
||
candidateIds,
|
||
question,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
candidateIds: number[]
|
||
question: GekanatorQuestion | null
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): boolean => {
|
||
if (!(question))
|
||
return false
|
||
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const yesCount = matchingPostCountInIds ({
|
||
candidateIds,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const noCount = candidateIds.length - yesCount
|
||
|
||
return yesCount > 0 && noCount > 0
|
||
}
|
||
|
||
|
||
const recalculateScores = ({
|
||
posts,
|
||
questions,
|
||
answers,
|
||
softenedQuestionIds,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): Map<number, number> => {
|
||
const questionById = new Map (questions.map (question => [question.id, question]))
|
||
const nextScores = new Map<number, number> ()
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
|
||
answers.forEach (answer => {
|
||
const question = questionById.get (answer.questionId)
|
||
if (!(question))
|
||
return
|
||
|
||
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
posts.forEach (post => {
|
||
const expected =
|
||
matched.has (post.id)
|
||
? 'yes'
|
||
: question.condition.type === 'post-similarity'
|
||
? expectedAnswerForQuestion (question, post)
|
||
: 'no'
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0)
|
||
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
|
||
})
|
||
})
|
||
|
||
return nextScores
|
||
}
|
||
|
||
|
||
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,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
scores: Map<number, number>
|
||
question: GekanatorQuestion
|
||
answer: GekanatorAnswerValue
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): AnswerPreview => {
|
||
const postById = new Map (posts.map (post => [post.id, post]))
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const nextPostIds = postIdsForHardAnswer ({
|
||
candidateIds: posts.map (post => post.id),
|
||
question,
|
||
answer,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
dynamicMatchIndex })
|
||
const nextPosts = nextPostIds
|
||
.map (postId => postById.get (postId))
|
||
.filter ((post): post is Post => post !== undefined)
|
||
if (nextPosts.length === 0)
|
||
return {
|
||
answer,
|
||
top: null,
|
||
candidateCount: 0,
|
||
effectiveCandidates: 0,
|
||
entropy: 0 }
|
||
|
||
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 'title-contains':
|
||
return condition.text
|
||
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 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,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
scores: Map<number, number>
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
gameSeed: string
|
||
recentFirstQuestionPenaltyById: Map<string, number>
|
||
userPriorWeights: Map<number, number>
|
||
performanceMode: GekanatorPerformanceMode
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): GekanatorQuestion | null => {
|
||
const candidateIds = posts.map (post => post.id)
|
||
const candidateIdSet = new Set (candidateIds)
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
|
||
const invertedSignature = (signature: string): string =>
|
||
signature.replace (/[01]/g, value => value === '1' ? '0' : '1')
|
||
|
||
const redundantSignatures = (
|
||
candidates: Post[],
|
||
): Set<string> => {
|
||
const signatures = new Set<string> ()
|
||
questions
|
||
.filter (question => askedIds.has (question.id))
|
||
.forEach (question => {
|
||
const signature = signatureForCandidateIds ({
|
||
candidateIds: candidates.map (post => post.id),
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
signatures.add (signature)
|
||
signatures.add (invertedSignature (signature))
|
||
})
|
||
|
||
return signatures
|
||
}
|
||
|
||
if (performanceMode === 'lite')
|
||
{
|
||
const nonTagCount =
|
||
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
|
||
const ranked = questions
|
||
.filter (question => !(askedIds.has (question.id)))
|
||
.map (question => {
|
||
if (isQuestionHardFilteredAfterAnswers (question, answers))
|
||
return null
|
||
|
||
const yes = matchingPostCountInIds ({
|
||
candidateIds,
|
||
candidateIdSet,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const no = posts.length - yes
|
||
if (yes === 0 || no === 0)
|
||
return null
|
||
|
||
const splitScore = Math.abs (posts.length / 2 - yes) / posts.length
|
||
const minSide = posts.length < 10 ? 1 : Math.max (2, Math.floor (posts.length * .08))
|
||
const narrowPenalty = yes < minSide || no < minSide ? .18 : 0
|
||
const tagPenalty = question.kind === 'tag' && nonTagCount < 3 ? .1 : 0
|
||
const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
|
||
const sourceBonus = sourcePriorityOffset (question)
|
||
const priorityBonus = priorityWeightOffset (question)
|
||
const categoryPenalty = questionCategoryPenalty (question, answers.length, 0)
|
||
|
||
return {
|
||
question,
|
||
score: splitScore * 100
|
||
+ narrowPenalty
|
||
+ tagPenalty
|
||
+ contradictionPenalty
|
||
+ sourceBonus
|
||
+ priorityBonus
|
||
+ categoryPenalty,
|
||
narrow: narrowPenalty > 0 }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
score: number
|
||
narrow: boolean } => item !== null && Number.isFinite (item.score))
|
||
.sort ((a, b) => {
|
||
if (a.score !== b.score)
|
||
return a.score - b.score
|
||
|
||
return a.question.id.localeCompare (b.question.id)
|
||
})
|
||
const pool = (
|
||
ranked.some (item => !(item.narrow))
|
||
? ranked.filter (item => !(item.narrow))
|
||
: ranked)
|
||
.slice (0, 8)
|
||
|
||
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.6) }))
|
||
const totalPoolWeight =
|
||
weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
|
||
const seed = `${ gameSeed }:lite:${ [...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 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 weightedEntropy = distributionEntropy (
|
||
normalisedWeightedPosts.map (item => item.weight))
|
||
|
||
const rank = (
|
||
questionsToRank: GekanatorQuestion[],
|
||
candidates: { post: Post; score: number }[],
|
||
weightedCandidates: { post: Post; score: number; weight: number }[],
|
||
) => {
|
||
const redundant = redundantSignatures (candidates.map (item => item.post))
|
||
const candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
|
||
const candidateIds = candidates.map (item => item.post.id)
|
||
const candidateIdSet = new Set (candidateIds)
|
||
const priorEntries = [...userPriorWeights.entries ()]
|
||
.filter (([postId]) => candidateById.has (postId))
|
||
const priorWeightTotal =
|
||
priorEntries.reduce ((sum, [, weight]) => sum + weight, 0)
|
||
const nonTagCount =
|
||
questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length
|
||
|
||
return questionsToRank
|
||
.map (question => {
|
||
if (isQuestionHardFilteredAfterAnswers (question, answers))
|
||
return null
|
||
|
||
const signature = signatureForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
if (redundant.has (signature))
|
||
return null
|
||
|
||
const yes = matchingPostCountInIds ({
|
||
candidateIds,
|
||
candidateIdSet,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const no = candidates.length - yes
|
||
if (yes === 0 || no === 0)
|
||
return null
|
||
|
||
const yesWeight = matchingWeightInCandidates ({
|
||
candidates: weightedCandidates.map (item => ({
|
||
post: item.post,
|
||
weight: item.weight })),
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const noWeight = 1 - yesWeight
|
||
if (yesWeight <= 0 || noWeight <= 0)
|
||
return null
|
||
if (Math.min (yesWeight, noWeight) < .08)
|
||
return null
|
||
|
||
const matched = matchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const yesPosteriorWeights = weightedCandidates
|
||
.filter (item => matched.has (item.post.id))
|
||
.map (item => item.weight / yesWeight)
|
||
const noPosteriorWeights = weightedCandidates
|
||
.filter (item => !(matched.has (item.post.id)))
|
||
.map (item => item.weight / noWeight)
|
||
const infoGain =
|
||
weightedEntropy
|
||
- (
|
||
yesWeight * distributionEntropy (yesPosteriorWeights)
|
||
+ noWeight * distributionEntropy (noPosteriorWeights))
|
||
if (infoGain < (candidates.length >= 10 ? .02 : .008))
|
||
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)
|
||
const repeatPenalty =
|
||
answers.length === 0
|
||
? (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
|
||
: 0
|
||
const categoryPenalty = questionCategoryPenalty (
|
||
question,
|
||
answers.length,
|
||
repeatPenalty)
|
||
const priorSplitScore =
|
||
priorWeightTotal <= 0
|
||
? null
|
||
: Math.abs (
|
||
.5 - (
|
||
priorEntries.reduce (
|
||
(sum, [postId, weight]) => {
|
||
return sum + (matched.has (postId) ? weight : 0)
|
||
},
|
||
0) / priorWeightTotal))
|
||
const priorBonus =
|
||
priorSplitScore === null
|
||
? 0
|
||
: Math.max (0, .22 - priorSplitScore) * -18
|
||
const infoGainBonus = -Math.min (1.2, infoGain) * 4
|
||
|
||
return { question,
|
||
score: weightedSplitScore * 100
|
||
+ unweightedSplitScore * 8
|
||
+ tagPenalty
|
||
+ narrowPenalty
|
||
+ contradictionPenalty
|
||
+ sourceBonus
|
||
+ priorityBonus
|
||
+ categoryPenalty
|
||
+ priorBonus
|
||
+ infoGainBonus,
|
||
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, 16)
|
||
|
||
if (pool.length === 0)
|
||
return null
|
||
|
||
const bestScore = pool[0]?.score ?? 0
|
||
const weightedPool = pool.map (item => ({
|
||
...item,
|
||
weight: Math.exp ((bestScore - item.score) / (answers.length === 0 ? 2.8 : 2.1)) }))
|
||
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 directWinningRunExampleAnswerFor = (
|
||
question: GekanatorQuestion,
|
||
targetPost: Post,
|
||
): GekanatorAnswerValue | null =>
|
||
question.kind !== 'post_similarity'
|
||
? null
|
||
: question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null
|
||
|
||
|
||
const winningRunTagText = (
|
||
category: string,
|
||
name: string,
|
||
): string => {
|
||
switch (category)
|
||
{
|
||
case 'nico':
|
||
return `ニコニコに「${ name.replace (/^nico:/, '') }」タグがついている?`
|
||
default:
|
||
return `「${ name }」タグがついている?`
|
||
}
|
||
}
|
||
|
||
|
||
const winningRunHostOf = (post: Post): string | null => {
|
||
try
|
||
{
|
||
return new URL (post.url).hostname.replace (/^www\./, '')
|
||
}
|
||
catch
|
||
{
|
||
return null
|
||
}
|
||
}
|
||
|
||
|
||
const winningRunOriginalDateOf = (post: Post): Date | null => {
|
||
const value = post.originalCreatedFrom || post.originalCreatedBefore
|
||
if (!(value))
|
||
return null
|
||
|
||
const date = new Date (value)
|
||
return Number.isNaN (date.getTime ()) ? null : date
|
||
}
|
||
|
||
|
||
const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] => {
|
||
const questions: GekanatorQuestion[] = []
|
||
const addQuestion = (question: GekanatorQuestion | null) => {
|
||
if (question)
|
||
questions.push (question)
|
||
}
|
||
const title = targetPost.title ?? ''
|
||
const titleWords =
|
||
Array.from (
|
||
new Set (
|
||
title.match (
|
||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu)
|
||
?? []))
|
||
.filter (word => word.length <= 24)
|
||
.slice (0, 8)
|
||
const host = winningRunHostOf (targetPost)
|
||
const originalDate = winningRunOriginalDateOf (targetPost)
|
||
const originalYear = originalDate?.getFullYear () ?? null
|
||
const originalMonth = originalDate?.getMonth () ?? null
|
||
const originalDay = originalDate?.getDate () ?? null
|
||
const monthDay =
|
||
originalMonth === null || originalDay === null
|
||
? null
|
||
: `${ originalMonth + 1 }-${ originalDay }`
|
||
const titleLength = title.length
|
||
|
||
addQuestion (
|
||
host === null
|
||
? null
|
||
: {
|
||
id: questionIdForCondition ({ type: 'source', host }),
|
||
text: `${ host } の投稿を思い浮かべている?`,
|
||
kind: 'source',
|
||
condition: { type: 'source', host },
|
||
source: 'default',
|
||
priorityWeight: 1.1,
|
||
test: post => winningRunHostOf (post) === host })
|
||
addQuestion (
|
||
originalYear === null
|
||
? null
|
||
: {
|
||
id: questionIdForCondition ({
|
||
type: 'original-year',
|
||
year: originalYear }),
|
||
text: `オリジナルの投稿年は ${ originalYear } 年?`,
|
||
kind: 'original_date',
|
||
condition: { type: 'original-year', year: originalYear },
|
||
source: 'default',
|
||
priorityWeight: 1.05,
|
||
test: post => winningRunOriginalDateOf (post)?.getFullYear () === originalYear })
|
||
addQuestion (
|
||
originalMonth === null
|
||
? null
|
||
: {
|
||
id: questionIdForCondition ({
|
||
type: 'original-month',
|
||
month: originalMonth + 1 }),
|
||
text: `オリジナルの投稿月は ${ originalMonth + 1 } 月?`,
|
||
kind: 'original_date',
|
||
condition: { type: 'original-month', month: originalMonth + 1 },
|
||
source: 'default',
|
||
priorityWeight: 1.02,
|
||
test: post => winningRunOriginalDateOf (post)?.getMonth () === originalMonth })
|
||
addQuestion (
|
||
monthDay === null
|
||
? null
|
||
: {
|
||
id: questionIdForCondition ({ type: 'original-month-day', monthDay }),
|
||
text: `オリジナルの投稿日は ${ originalMonth! + 1 } 月 ${ originalDay! } 日?`,
|
||
kind: 'original_date',
|
||
condition: { type: 'original-month-day', monthDay },
|
||
source: 'default',
|
||
priorityWeight: .98,
|
||
test: post => {
|
||
const postDate = winningRunOriginalDateOf (post)
|
||
return postDate !== null
|
||
&& `${ postDate.getMonth () + 1 }-${ postDate.getDate () }` === monthDay
|
||
} })
|
||
const winningRunTitleLengths = [
|
||
Math.max (1, titleLength - 4),
|
||
titleLength,
|
||
titleLength + 4]
|
||
.filter ((length: number, index: number, values: number[]) =>
|
||
titleLength > 0
|
||
&& length > 0
|
||
&& values.indexOf (length) === index)
|
||
winningRunTitleLengths.forEach ((length: number, index: number) => {
|
||
addQuestion ({
|
||
id: questionIdForCondition ({
|
||
type: 'title-length-at-least',
|
||
length }),
|
||
text: `タイトルは ${ length } 文字以上?`,
|
||
kind: 'title',
|
||
condition: { type: 'title-length-at-least', length },
|
||
source: 'default',
|
||
priorityWeight: index === 1 ? 1.08 : 1.01,
|
||
test: post => (post.title?.length ?? 0) >= length })
|
||
})
|
||
addQuestion ({
|
||
id: questionIdForCondition ({ type: 'title-has-ascii' }),
|
||
text: '題名に英数字が混じっている?',
|
||
kind: 'title',
|
||
condition: { type: 'title-has-ascii' },
|
||
source: 'default',
|
||
priorityWeight: .96,
|
||
test: post => /[A-Za-z0-9]/.test (post.title ?? '') })
|
||
titleWords.forEach (word => {
|
||
addQuestion ({
|
||
id: questionIdForCondition ({ type: 'title-contains', text: word }),
|
||
text: `題名に「${ word }」が含まれる?`,
|
||
kind: 'title',
|
||
condition: { type: 'title-contains', text: word },
|
||
source: 'default',
|
||
priorityWeight: 1.07,
|
||
test: post => (post.title ?? '').includes (word) })
|
||
})
|
||
|
||
targetPost.tags
|
||
.filter (tag =>
|
||
tag.category !== 'meta'
|
||
&& !(tag.name.includes ('タグ希望'))
|
||
&& !(tag.name.includes ('bot操作')))
|
||
.slice (0, 20)
|
||
.forEach (tag => {
|
||
addQuestion ({
|
||
id: questionIdForCondition ({
|
||
type: 'tag',
|
||
key: `${ tag.category }:${ tag.name }` }),
|
||
text: winningRunTagText (tag.category, tag.name),
|
||
kind: 'tag',
|
||
condition: { type: 'tag', key: `${ tag.category }:${ tag.name }` },
|
||
source: 'default',
|
||
priorityWeight: 1.12,
|
||
test: post => post.tags.some (candidate =>
|
||
candidate.category === tag.category
|
||
&& candidate.name === tag.name
|
||
&& candidate.category !== 'meta'
|
||
&& !(candidate.name.includes ('タグ希望'))
|
||
&& !(candidate.name.includes ('bot操作'))) })
|
||
})
|
||
|
||
void ([
|
||
{
|
||
answer: 'yes' as const,
|
||
threshold: .9,
|
||
text: 'その投稿そのものと言ってよさそう?' },
|
||
{
|
||
answer: 'partial' as const,
|
||
threshold: .6,
|
||
text: 'かなり近いイメージ?' },
|
||
{
|
||
answer: 'no' as const,
|
||
threshold: .25,
|
||
text: '少し違う印象もある?' }]).forEach ((item, index) => {
|
||
addQuestion ({
|
||
id: `winning-run:post-similarity:${ targetPost.id }:${ item.answer }:${ item.threshold }`,
|
||
text: item.text,
|
||
kind: 'post_similarity',
|
||
condition: {
|
||
type: 'post-similarity',
|
||
postId: targetPost.id,
|
||
answer: item.answer,
|
||
threshold: item.threshold },
|
||
source: 'default',
|
||
priorityWeight: 1 - index * .04,
|
||
exampleAnswers: {
|
||
[String (targetPost.id) as `${ number }`]: item.answer },
|
||
test: post => post.id === targetPost.id })
|
||
})
|
||
|
||
return questions
|
||
}
|
||
|
||
|
||
const winningRunPriorityFor = (
|
||
question: GekanatorQuestion,
|
||
expected: GekanatorAnswerValue,
|
||
targetPost: Post,
|
||
): number | null => {
|
||
if (question.kind === 'post_similarity')
|
||
{
|
||
const directAnswer = directWinningRunExampleAnswerFor (question, targetPost)
|
||
if (directAnswer === null)
|
||
return null
|
||
}
|
||
|
||
if (expected === 'yes')
|
||
return 0
|
||
if (expected === 'partial')
|
||
return 1
|
||
if (expected === 'no' || expected === 'probably_no')
|
||
return 2
|
||
|
||
return null
|
||
}
|
||
|
||
|
||
const chooseWinningRunQuestion = ({
|
||
posts,
|
||
targetPost,
|
||
answers,
|
||
askedIds,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
targetPost: Post
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): GekanatorQuestion | null => {
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const ranked = mergeQuestions (winningRunCandidateQuestionsFor (targetPost))
|
||
.filter (question => {
|
||
if (askedIds.has (question.id))
|
||
return false
|
||
if (isQuestionHardFilteredAfterAnswers (question, answers))
|
||
return false
|
||
|
||
const expected = expectedAnswerForQuestion (question, targetPost)
|
||
return expected !== null && expected !== 'unknown'
|
||
})
|
||
.map (question => {
|
||
const expected = expectedAnswerForQuestion (question, targetPost)
|
||
const priority =
|
||
expected === null
|
||
? null
|
||
: winningRunPriorityFor (question, expected, targetPost)
|
||
if (priority === null)
|
||
return null
|
||
|
||
const yesCount = matchingPostCountInIds ({
|
||
candidateIds: posts.map (post => post.id),
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const matchingCount =
|
||
expected === 'yes' || expected === 'partial'
|
||
? yesCount
|
||
: posts.length - yesCount
|
||
|
||
return {
|
||
question,
|
||
priority,
|
||
matchingCount }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
priority: number
|
||
matchingCount: number } => item !== null)
|
||
.sort ((a, b) => {
|
||
if (a.priority !== b.priority)
|
||
return a.priority - b.priority
|
||
|
||
if (a.question.priorityWeight !== b.question.priorityWeight)
|
||
return b.question.priorityWeight - a.question.priorityWeight
|
||
|
||
if (a.matchingCount !== b.matchingCount)
|
||
return a.matchingCount - b.matchingCount
|
||
|
||
return a.question.id.localeCompare (b.question.id)
|
||
})
|
||
|
||
if (ranked.length > 0)
|
||
return ranked[0]?.question ?? null
|
||
|
||
return null
|
||
}
|
||
|
||
|
||
const chooseFallbackQuestion = ({
|
||
posts,
|
||
allPosts,
|
||
questions,
|
||
answers,
|
||
askedIds,
|
||
scores,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
allPosts: Post[]
|
||
questions: GekanatorQuestion[]
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
scores: Map<number, number>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): GekanatorQuestion | null => {
|
||
if (posts.length === 0)
|
||
return null
|
||
|
||
const fallbackPosts = posts
|
||
.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
|
||
.sort ((a, b) => b.score - a.score)
|
||
.slice (0, Math.min (6, posts.length))
|
||
.map (item => item.post)
|
||
const fallbackQuestions = mergeQuestions (
|
||
fallbackPosts.flatMap (post => winningRunCandidateQuestionsFor (post)))
|
||
.slice (0, 32)
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const candidateIds = posts.map (post => post.id)
|
||
const ranked = mergeQuestions ([
|
||
...questions,
|
||
...fallbackQuestions])
|
||
.filter (question =>
|
||
!(askedIds.has (question.id))
|
||
&& !(isQuestionHardFilteredAfterAnswers (question, answers)))
|
||
.map (question => {
|
||
const yesCount = matchingPostCountInIds ({
|
||
candidateIds,
|
||
posts: allPosts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
const noCount = candidateIds.length - yesCount
|
||
if (yesCount === 0 || noCount === 0)
|
||
return null
|
||
|
||
return {
|
||
question,
|
||
knownCount: candidateIds.length,
|
||
balance: Math.abs (yesCount - noCount) }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
knownCount: number
|
||
balance: number } => item !== null)
|
||
.sort ((a, b) => {
|
||
if (a.balance !== b.balance)
|
||
return a.balance - b.balance
|
||
|
||
if (a.knownCount !== b.knownCount)
|
||
return b.knownCount - a.knownCount
|
||
|
||
if (a.question.priorityWeight !== b.question.priorityWeight)
|
||
return b.question.priorityWeight - a.question.priorityWeight
|
||
|
||
return a.question.id.localeCompare (b.question.id)
|
||
})
|
||
|
||
return ranked[0]?.question ?? null
|
||
}
|
||
|
||
|
||
const shouldEnterGuessPhase = (
|
||
reason: GuessReason | null,
|
||
): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
|
||
(reason === 'hard_max_questions'
|
||
|| reason === 'winning_run_finished'
|
||
|| reason === 'question_count_checkpoint')
|
||
|
||
|
||
const isWinningRunActive = (
|
||
winningRunTargetId: number | null,
|
||
winningRunStartAnswerCount: number | null,
|
||
): boolean =>
|
||
winningRunTargetId !== null && winningRunStartAnswerCount !== null
|
||
|
||
|
||
const winningRunQuestionCount = (
|
||
answers: GekanatorAnswerLog[],
|
||
winningRunStartAnswerCount: number | null,
|
||
): number => winningRunStartAnswerCount === null
|
||
? 0
|
||
: answers
|
||
.slice (winningRunStartAnswerCount)
|
||
.filter (answer => answer.questionMode === 'winning_run')
|
||
.length
|
||
|
||
|
||
const nextQuestionPlanFor = (
|
||
{ posts,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }: { posts: Post[]
|
||
eligiblePosts: Post[]
|
||
availablePosts: Post[]
|
||
acceptedQuestions: GekanatorQuestion[]
|
||
scores: Map<number, number>
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
gameSeed: string
|
||
recentFirstQuestionPenaltyById: Map<string, number>
|
||
userPriorWeights: Map<number, number>
|
||
performanceMode: GekanatorPerformanceMode
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
lastGuessQuestionCount: number
|
||
winningRunTargetId: number | null
|
||
winningRunStartAnswerCount: number | null },
|
||
): { question: GekanatorQuestion | null
|
||
guess: Post | null
|
||
guessReason: GuessReason | null
|
||
questionMode: QuestionMode
|
||
winningRunTargetId: number | null
|
||
winningRunStartAnswerCount: number | null } => {
|
||
const guessablePosts =
|
||
eligiblePosts.length > 0
|
||
? eligiblePosts
|
||
: availablePosts
|
||
|
||
const checkpointGuess =
|
||
answers.length > 0
|
||
&& answers.length - lastGuessQuestionCount >= minQuestionsBeforeCertainGuess
|
||
|
||
if (answers.length >= hardMaxQuestions)
|
||
{
|
||
return {
|
||
question: null,
|
||
guess: bestPost (guessablePosts, scores),
|
||
guessReason: 'hard_max_questions',
|
||
questionMode: null,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }
|
||
}
|
||
|
||
if (checkpointGuess)
|
||
{
|
||
return {
|
||
question: null,
|
||
guess: bestPost (guessablePosts, scores),
|
||
guessReason: 'question_count_checkpoint',
|
||
questionMode: null,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }
|
||
}
|
||
|
||
const nextWinningRunTargetId =
|
||
eligiblePosts.length === 1
|
||
? eligiblePosts[0]?.id ?? null
|
||
: null
|
||
const nextWinningRunStartAnswerCount =
|
||
nextWinningRunTargetId === null
|
||
? null
|
||
: ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
|
||
&& winningRunTargetId === nextWinningRunTargetId
|
||
&& winningRunStartAnswerCount !== null)
|
||
? winningRunStartAnswerCount
|
||
: answers.length)
|
||
const nextWinningRunTargetPost =
|
||
nextWinningRunTargetId === null
|
||
? null
|
||
: posts.find (post => post.id === nextWinningRunTargetId) ?? null
|
||
const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] =>
|
||
buildQuestionsForCandidateIds ({
|
||
candidateIds: scopePosts.map (post => post.id),
|
||
materialIndex,
|
||
performanceMode,
|
||
acceptedQuestions })
|
||
|
||
if (eligiblePosts.length === 1)
|
||
{
|
||
const winningRunFinished =
|
||
nextWinningRunTargetId !== null
|
||
&& nextWinningRunStartAnswerCount !== null
|
||
&& eligiblePosts[0]?.id === nextWinningRunTargetId
|
||
&& winningRunQuestionCount (
|
||
answers,
|
||
nextWinningRunStartAnswerCount) >= winningRunQuestionLimit
|
||
if (winningRunFinished)
|
||
return {
|
||
question: null,
|
||
guess: bestPost (eligiblePosts, scores),
|
||
guessReason: 'winning_run_finished',
|
||
questionMode: null,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount === null)
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
|
||
const winningRunQuestion = chooseWinningRunQuestion ({
|
||
posts,
|
||
targetPost: nextWinningRunTargetPost,
|
||
answers,
|
||
askedIds,
|
||
materialIndex,
|
||
matchIndex })
|
||
if (winningRunQuestion)
|
||
return {
|
||
question: winningRunQuestion,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: 'winning_run',
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
}
|
||
|
||
const evaluationPosts =
|
||
eligiblePosts
|
||
|
||
const evaluationQuestions = buildQuestionsForPosts (evaluationPosts)
|
||
const normalQuestion = chooseQuestion ({
|
||
posts: evaluationPosts,
|
||
questions: evaluationQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex })
|
||
|
||
const fallbackQuestion = normalQuestion ?? chooseFallbackQuestion ({
|
||
posts: evaluationPosts,
|
||
allPosts: posts,
|
||
questions: evaluationQuestions,
|
||
answers,
|
||
askedIds,
|
||
scores,
|
||
materialIndex,
|
||
matchIndex })
|
||
|
||
if (fallbackQuestion)
|
||
{
|
||
return {
|
||
question: fallbackQuestion,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: 'normal',
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
}
|
||
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
}
|
||
|
||
|
||
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 backgroundThumbnailUrl = (post: Post): string | undefined =>
|
||
post.thumbnail || post.thumbnailBase || undefined
|
||
|
||
|
||
const mascotStateFor = (
|
||
phase: Phase,
|
||
resultWon: boolean | null,
|
||
eligiblePostCount: number,
|
||
bestConfidencePercent: number,
|
||
winningRunActive: boolean,
|
||
): MascotState => {
|
||
const resultPhase =
|
||
phase === 'end'
|
||
|| phase === 'review'
|
||
|| phase === 'learned'
|
||
|
||
if (resultPhase && !(resultWon))
|
||
return 'failed'
|
||
|
||
if (resultPhase && resultWon)
|
||
return 'celebrate'
|
||
|
||
switch (phase)
|
||
{
|
||
case 'question':
|
||
case 'continue':
|
||
case 'extra_questions':
|
||
case 'question_suggestion':
|
||
if (
|
||
winningRunActive
|
||
|| eligiblePostCount <= 2
|
||
|| bestConfidencePercent >= 70
|
||
)
|
||
return 'thinking_near'
|
||
if (
|
||
eligiblePostCount >= 15
|
||
&& bestConfidencePercent < 45
|
||
)
|
||
return 'thinking_far'
|
||
return 'thinking_mid'
|
||
case 'guess':
|
||
case 'end':
|
||
case 'review':
|
||
case 'learned':
|
||
return 'confident'
|
||
default:
|
||
return 'idle'
|
||
}
|
||
}
|
||
|
||
|
||
const backgroundPostsFor = ({
|
||
phase,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
displayedGuess,
|
||
reviewCorrectPost,
|
||
reviewGuessedPost,
|
||
}: {
|
||
phase: Phase
|
||
eligiblePosts: Post[]
|
||
availablePosts: Post[]
|
||
displayedGuess: Post | null
|
||
reviewCorrectPost: Post | null
|
||
reviewGuessedPost: Post | null
|
||
}): Post[] => {
|
||
const focusPosts =
|
||
phase === 'end' || phase === 'review' || phase === 'learned'
|
||
? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post !== null)
|
||
: phase === 'guess'
|
||
? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post !== null)
|
||
: eligiblePosts.length > 0
|
||
? eligiblePosts
|
||
: availablePosts
|
||
|
||
return [...new Map (focusPosts.map (post => [post.id, post])).values ()]
|
||
}
|
||
|
||
|
||
const GekanatorBackdrop: FC<{
|
||
posts: Post[]
|
||
mascotAsset: string
|
||
phase: Phase
|
||
displayedGuess?: Post | null
|
||
visualSeed: string
|
||
motionMode: BackgroundMotionMode
|
||
winningRunTargetPost?: Post | null
|
||
winningRunQuestionCount?: number }> = ({ posts,
|
||
mascotAsset,
|
||
phase,
|
||
displayedGuess = null,
|
||
visualSeed,
|
||
motionMode,
|
||
winningRunTargetPost = null,
|
||
winningRunQuestionCount = 0 }) => {
|
||
const guessFocusOffset = useMemo (() => {
|
||
const focusTiles = [
|
||
{ x: 'calc(max(100vw, 100vh) * 0.5)',
|
||
y: 'calc(max(100vw, 100vh) * 0.5)' },
|
||
{ x: 'calc(max(100vw, 100vh) * -0.5)',
|
||
y: 'calc(max(100vw, 100vh) * 0.5)' },
|
||
{ x: 'calc(max(100vw, 100vh) * 0.5)',
|
||
y: 'calc(max(100vw, 100vh) * -0.5)' },
|
||
{ x: 'calc(max(100vw, 100vh) * -0.5)',
|
||
y: 'calc(max(100vw, 100vh) * -0.5)' }]
|
||
|
||
return (focusTiles[Math.abs (hashString (`${ visualSeed }:guess-focus`)) % focusTiles.length]
|
||
?? focusTiles[0])
|
||
}, [visualSeed])
|
||
|
||
const directions = useMemo (
|
||
() => [
|
||
{ x: 0, y: -33.333333 },
|
||
{ x: 33.333333, y: -33.333333 },
|
||
{ x: 33.333333, y: 0 },
|
||
{ x: 33.333333, y: 33.333333 },
|
||
{ x: 0, y: 33.333333 },
|
||
{ x: -33.333333, y: 33.333333 },
|
||
{ x: -33.333333, y: 0 },
|
||
{ x: -33.333333, y: -33.333333 }],
|
||
[])
|
||
const guessThumbnail =
|
||
phase === 'guess' && displayedGuess
|
||
? backgroundThumbnailUrl (displayedGuess)
|
||
: null
|
||
const isWinningRunBackdrop =
|
||
!(guessThumbnail)
|
||
&& phase === 'question'
|
||
&& winningRunTargetPost !== null
|
||
&& Boolean (backgroundThumbnailUrl (winningRunTargetPost))
|
||
const backdropMode =
|
||
guessThumbnail
|
||
? 'guess'
|
||
: isWinningRunBackdrop
|
||
? 'winning_run'
|
||
: 'normal'
|
||
|
||
const normalVisiblePosts = useMemo (
|
||
() => posts
|
||
.filter (post => Boolean (backgroundThumbnailUrl (post)))
|
||
.sort ((left, right) =>
|
||
hashString (`${ visualSeed }:${ left.id }`)
|
||
- hashString (`${ visualSeed }:${ right.id }`))
|
||
.slice (0, motionMode === 'calm' ? 24 : 36),
|
||
[posts, visualSeed, motionMode])
|
||
|
||
const settingsForMode = useCallback (
|
||
(
|
||
mode: 'normal' | 'winning_run' | 'guess',
|
||
): { columns: number; rows: number; opacity: number } => {
|
||
if (mode === 'winning_run' || mode === 'guess')
|
||
return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }
|
||
|
||
return motionMode === 'calm'
|
||
? { columns: 7, rows: 7, opacity: .14 }
|
||
: { columns: 10, rows: 10, opacity: .2 }
|
||
},
|
||
[motionMode])
|
||
|
||
const scaleForMode = useCallback (
|
||
(
|
||
mode: 'normal' | 'winning_run' | 'guess',
|
||
displayedWinningCount: number,
|
||
): number => {
|
||
if (mode === 'guess')
|
||
return 8
|
||
|
||
if (mode === 'winning_run')
|
||
return [1, 8 / 6, 8 / 4, 8 / 2][Math.max (0, Math.min (3, displayedWinningCount))] ?? 1
|
||
|
||
return 1
|
||
},
|
||
[])
|
||
|
||
const postsForMode = useCallback ((
|
||
mode: 'normal' | 'winning_run' | 'guess',
|
||
): Post[] => {
|
||
if (mode === 'guess' && displayedGuess)
|
||
return [displayedGuess]
|
||
if (mode === 'winning_run' && winningRunTargetPost)
|
||
return [winningRunTargetPost]
|
||
return normalVisiblePosts
|
||
}, [displayedGuess, winningRunTargetPost, normalVisiblePosts])
|
||
|
||
const thumbnailsForMode = useCallback ((
|
||
mode: 'normal' | 'winning_run' | 'guess',
|
||
count: number,
|
||
): string[] => {
|
||
const modePosts = postsForMode (mode)
|
||
if (modePosts.length === 0)
|
||
return []
|
||
|
||
return Array.from ({ length: count }, (_, index) => {
|
||
const post = modePosts[index % modePosts.length]
|
||
return backgroundThumbnailUrl (post) ?? null
|
||
}).filter ((thumbnail): thumbnail is string => Boolean (thumbnail))
|
||
}, [postsForMode])
|
||
|
||
const targetSettings = settingsForMode (backdropMode)
|
||
const targetTileCount = targetSettings.columns * targetSettings.rows
|
||
|
||
const nextThumbnails = useMemo (
|
||
() => thumbnailsForMode (backdropMode, targetTileCount),
|
||
[backdropMode, targetTileCount, thumbnailsForMode])
|
||
|
||
const nextDirection = useMemo (
|
||
() => directions[
|
||
Math.abs (hashString (`${ visualSeed }:direction`)) % directions.length]
|
||
?? directions[0],
|
||
[visualSeed, directions])
|
||
|
||
const marqueeDuration =
|
||
backdropMode === 'winning_run'
|
||
? motionMode === 'calm' ? 28 : 20
|
||
: motionMode === 'calm' ? 34 : 24
|
||
const tileFlipDuration = motionMode === 'calm' ? .6 : .45
|
||
const x = useMotionValue (0)
|
||
const y = useMotionValue (0)
|
||
const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)`
|
||
const [activeDirection, setActiveDirection] = useState (nextDirection)
|
||
const activeDirectionRef = useRef (activeDirection)
|
||
const flipTimerRef = useRef<number | null> (null)
|
||
const [displayedBackdropMode, setDisplayedBackdropMode] =
|
||
useState<'normal' | 'winning_run' | 'guess'> (backdropMode)
|
||
const [displayedWinningRunCount, setDisplayedWinningRunCount] =
|
||
useState (winningRunQuestionCount)
|
||
const [displayedThumbnails, setDisplayedThumbnails] = useState<string[]> (
|
||
nextThumbnails)
|
||
const [fromThumbnails, setFromThumbnails] = useState<string[]> (
|
||
nextThumbnails)
|
||
const [toThumbnails, setToThumbnails] = useState<string[]> (
|
||
nextThumbnails)
|
||
const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed)
|
||
const [isFlippingTiles, setIsFlippingTiles] = useState (false)
|
||
const renderedSettings = settingsForMode (displayedBackdropMode)
|
||
const renderedTileCount =
|
||
renderedSettings.columns * renderedSettings.rows
|
||
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount)
|
||
const isGuessPresentation =
|
||
backdropMode === 'guess' || displayedBackdropMode === 'guess'
|
||
|
||
useEffect (() => {
|
||
if (motionMode === 'off')
|
||
return
|
||
|
||
if (!(isGuessPresentation))
|
||
return
|
||
|
||
const duration = motionMode === 'calm' ? .95 : .75
|
||
const ease = [0.16, 1, 0.3, 1] as const
|
||
|
||
const controls = [
|
||
animate (x, 0, { duration, ease }),
|
||
animate (y, 0, { duration, ease })]
|
||
|
||
return () => {
|
||
controls.forEach (control => control.stop ())
|
||
}
|
||
}, [isGuessPresentation, motionMode, visualSeed, x, y])
|
||
|
||
useEffect (() => {
|
||
activeDirectionRef.current = activeDirection
|
||
}, [activeDirection])
|
||
|
||
useEffect (() => {
|
||
const wrap = (value: number): number => {
|
||
const cell = 33.333333
|
||
const wrapped = ((value % cell) + cell) % cell
|
||
return wrapped > cell / 2 ? wrapped - cell : wrapped
|
||
}
|
||
|
||
if (motionMode === 'off' || nextThumbnails.length === 0)
|
||
{
|
||
x.set (0)
|
||
y.set (0)
|
||
return
|
||
}
|
||
|
||
if (isGuessPresentation)
|
||
return
|
||
|
||
const speed = 33.333333 / marqueeDuration
|
||
let animationFrame: number
|
||
let previousTime = performance.now ()
|
||
|
||
const tick = (time: number) => {
|
||
const elapsedSeconds = (time - previousTime) / 1000
|
||
previousTime = time
|
||
const direction = activeDirectionRef.current
|
||
x.set (wrap (x.get () + Math.sign (direction.x) * speed * elapsedSeconds))
|
||
y.set (wrap (y.get () + Math.sign (direction.y) * speed * elapsedSeconds))
|
||
animationFrame = window.requestAnimationFrame (tick)
|
||
}
|
||
|
||
animationFrame = window.requestAnimationFrame (tick)
|
||
|
||
return () => window.cancelAnimationFrame (animationFrame)
|
||
}, [
|
||
x,
|
||
y,
|
||
marqueeDuration,
|
||
motionMode,
|
||
isGuessPresentation,
|
||
nextThumbnails.length])
|
||
|
||
useEffect (() => {
|
||
const applyDirection = () => {
|
||
activeDirectionRef.current = nextDirection
|
||
setActiveDirection (nextDirection)
|
||
}
|
||
|
||
if (flipTimerRef.current !== null) {
|
||
window.clearTimeout (flipTimerRef.current)
|
||
flipTimerRef.current = null
|
||
}
|
||
|
||
if (motionMode === 'off') {
|
||
applyDirection ()
|
||
setIsFlippingTiles (false)
|
||
setFlipVisualSeed (visualSeed)
|
||
return
|
||
}
|
||
|
||
if (backdropMode === 'guess' && guessThumbnail) {
|
||
setIsFlippingTiles (false)
|
||
setDisplayedBackdropMode ('guess')
|
||
setDisplayedWinningRunCount (winningRunQuestionCount)
|
||
setDisplayedThumbnails (nextThumbnails)
|
||
setFromThumbnails (nextThumbnails)
|
||
setToThumbnails (nextThumbnails)
|
||
setFlipVisualSeed (visualSeed)
|
||
return
|
||
}
|
||
|
||
if (
|
||
displayedBackdropMode === 'winning_run'
|
||
&& backdropMode === 'winning_run'
|
||
) {
|
||
applyDirection ()
|
||
setDisplayedBackdropMode ('winning_run')
|
||
setDisplayedWinningRunCount (winningRunQuestionCount)
|
||
setDisplayedThumbnails (nextThumbnails)
|
||
setFromThumbnails (nextThumbnails)
|
||
setToThumbnails (nextThumbnails)
|
||
setIsFlippingTiles (false)
|
||
setFlipVisualSeed (visualSeed)
|
||
return
|
||
}
|
||
|
||
if (nextThumbnails.length === 0) {
|
||
applyDirection ()
|
||
setIsFlippingTiles (false)
|
||
setFlipVisualSeed (visualSeed)
|
||
return
|
||
}
|
||
|
||
const sameTiles =
|
||
displayedThumbnails.length === nextThumbnails.length
|
||
&& displayedThumbnails.every (
|
||
(thumbnail, index) => thumbnail === nextThumbnails[index])
|
||
if (sameTiles && flipVisualSeed === visualSeed) {
|
||
if (
|
||
activeDirection.x !== nextDirection.x
|
||
|| activeDirection.y !== nextDirection.y
|
||
)
|
||
applyDirection ()
|
||
return
|
||
}
|
||
|
||
const currentThumbnails =
|
||
displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails
|
||
|
||
setFromThumbnails (currentThumbnails)
|
||
setToThumbnails (nextThumbnails)
|
||
setIsFlippingTiles (true)
|
||
|
||
flipTimerRef.current = window.setTimeout (() => {
|
||
setDisplayedBackdropMode (backdropMode)
|
||
setDisplayedWinningRunCount (winningRunQuestionCount)
|
||
setDisplayedThumbnails (nextThumbnails)
|
||
setFromThumbnails (nextThumbnails)
|
||
setToThumbnails (nextThumbnails)
|
||
setIsFlippingTiles (false)
|
||
applyDirection ()
|
||
setFlipVisualSeed (visualSeed)
|
||
flipTimerRef.current = null
|
||
}, tileFlipDuration * 1000)
|
||
|
||
return () => {
|
||
if (flipTimerRef.current !== null) {
|
||
window.clearTimeout (flipTimerRef.current)
|
||
flipTimerRef.current = null
|
||
}
|
||
}
|
||
}, [
|
||
motionMode,
|
||
backdropMode,
|
||
displayedBackdropMode,
|
||
guessThumbnail,
|
||
nextThumbnails,
|
||
nextDirection,
|
||
displayedThumbnails,
|
||
flipVisualSeed,
|
||
visualSeed,
|
||
activeDirection,
|
||
winningRunQuestionCount,
|
||
tileFlipDuration,
|
||
x,
|
||
y])
|
||
|
||
if (motionMode === 'off' || nextThumbnails.length === 0)
|
||
return (
|
||
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
|
||
to-pink-50 dark:from-red-950 dark:via-red-975 dark:to-red-900"/>)
|
||
|
||
return (
|
||
<div className="fixed [inset:48px_0_0_0] z-0 overflow-hidden pointer-events-none">
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<motion.div
|
||
className="relative shrink-0"
|
||
style={{
|
||
transform: marqueeTransform,
|
||
width: 'calc(max(100vw, 100vh) * 3)',
|
||
height: 'calc(max(100vw, 100vh) * 3)' }}>
|
||
<motion.div
|
||
className="relative h-full w-full"
|
||
animate={{ scale: renderedScale,
|
||
x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
|
||
y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
|
||
transition={(displayedBackdropMode === 'winning_run'
|
||
|| displayedBackdropMode === 'guess')
|
||
? { duration: motionMode === 'calm' ? .95 : .75,
|
||
ease: [.16, 1, .3, 1] }
|
||
: { duration: .2 }}>
|
||
{Array.from ({ length: 9 }, (_, duplicate) => {
|
||
const column = duplicate % 3
|
||
const row = Math.floor (duplicate / 3)
|
||
|
||
return (
|
||
<motion.div
|
||
key={duplicate}
|
||
className="absolute grid overflow-hidden"
|
||
layout={displayedBackdropMode !== 'normal'}
|
||
style={{
|
||
left: `${ column * 33.333333 }%`,
|
||
top: `${ row * 33.333333 }%`,
|
||
width: '33.333333%',
|
||
height: '33.333333%',
|
||
gridTemplateColumns:
|
||
`repeat(${ renderedSettings.columns }, minmax(0, 1fr))`,
|
||
gridTemplateRows:
|
||
`repeat(${ renderedSettings.rows }, minmax(0, 1fr))` }}
|
||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
|
||
{Array.from ({ length: renderedTileCount }, (_, index) => {
|
||
const currentThumbnail =
|
||
displayedThumbnails[
|
||
index % Math.max (displayedThumbnails.length, 1)]
|
||
const frontThumbnail =
|
||
isFlippingTiles
|
||
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
|
||
: currentThumbnail
|
||
const backThumbnail =
|
||
isFlippingTiles
|
||
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
|
||
: currentThumbnail
|
||
const thumbnail =
|
||
displayedBackdropMode === 'winning_run'
|
||
|| displayedBackdropMode === 'guess'
|
||
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
|
||
: currentThumbnail
|
||
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
|
||
return null
|
||
|
||
return (
|
||
<motion.div
|
||
key={`${ duplicate }:${ index }`}
|
||
className="relative overflow-hidden"
|
||
layout={displayedBackdropMode !== 'normal'}
|
||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
|
||
style={{ perspective: 1600 }}>
|
||
{(displayedBackdropMode !== 'normal' || !(isFlippingTiles))
|
||
? (
|
||
<img
|
||
src={['intro', 'end'].includes (phase)
|
||
? mascotAsset
|
||
: thumbnail}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
style={{ opacity: renderedSettings.opacity }}/>)
|
||
: (
|
||
<motion.div
|
||
className="absolute inset-0"
|
||
initial={{ rotateY: 0 }}
|
||
animate={{ rotateY: 180 }}
|
||
transition={{
|
||
duration: tileFlipDuration,
|
||
ease: 'easeInOut' }}
|
||
style={{ transformStyle: 'preserve-3d' }}>
|
||
<img
|
||
src={backThumbnail}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
style={{
|
||
backfaceVisibility: 'hidden',
|
||
opacity: renderedSettings.opacity,
|
||
transform: 'rotateY(180deg)' }}/>
|
||
<img
|
||
src={frontThumbnail}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
style={{
|
||
backfaceVisibility: 'hidden',
|
||
opacity: renderedSettings.opacity }}/>
|
||
</motion.div>)}
|
||
</motion.div>)
|
||
})}
|
||
</motion.div>)
|
||
})}
|
||
</motion.div>
|
||
</motion.div>
|
||
</div>
|
||
<div className="fixed inset-0 z-0 bg-gradient-to-br from-yellow-50/76 via-white/58
|
||
to-pink-100/62 dark:from-red-950/78 dark:via-red-975/60
|
||
dark:to-red-900/66"/>
|
||
</div>)
|
||
}
|
||
|
||
|
||
const expectedAnswerFor = (
|
||
question: GekanatorQuestion | undefined,
|
||
correctPost: Post | null,
|
||
): GekanatorAnswerValue | null =>
|
||
expectedAnswerForQuestion (question, correctPost)
|
||
|
||
|
||
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||
const storedGame = useMemo (loadStoredGame, [])
|
||
const hasStoredRestore = storedGame !== null && isStoredPhase (storedGame.phase)
|
||
const queryClient = useQueryClient ()
|
||
const isAdmin = user?.role === 'admin'
|
||
const canPersistGame = user !== null
|
||
const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
|
||
() => loadRecentGames ())
|
||
const [performanceMode] =
|
||
useState<GekanatorPerformanceMode> (() => loadPerformanceMode ())
|
||
const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
|
||
() => loadBackgroundMotionMode (loadPerformanceMode ()))
|
||
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
|
||
const [gameSeed, setGameSeed] = useState (
|
||
storedGame?.gameSeed ?? createGameSeed ())
|
||
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
|
||
const [phase, setPhase] = useState<Phase> (
|
||
hasStoredRestore ? 'intro' : 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 [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState<Map<number, number>> (
|
||
() => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? []))
|
||
const [recoveryStepCount, setRecoveryStepCount] = useState (
|
||
storedGame?.recoveryStepCount ?? 0)
|
||
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 [winningRunTargetId, setWinningRunTargetId] = useState<number | null> (
|
||
storedGame?.winningRunTargetId ?? null)
|
||
const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] =
|
||
useState<number | null> (storedGame?.winningRunStartAnswerCount ?? null)
|
||
const [guessReason, setGuessReason] = useState<GuessReason | null> (
|
||
storedGame?.guessReason ?? 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 })
|
||
const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts])
|
||
const acceptedQuestionMatchIndex = useMemo (
|
||
() => buildGekanatorMatchIndex (posts, acceptedQuestions),
|
||
[posts, acceptedQuestions])
|
||
|
||
useEffect (() => {
|
||
if (
|
||
posts.length === 0
|
||
|| storedAskedQuestionBankIds.length === 0
|
||
|| !(acceptedQuestionsFetched)
|
||
)
|
||
return
|
||
|
||
const questionById = new Map (
|
||
mergeQuestions ([
|
||
...acceptedQuestions,
|
||
...askedQuestionBank])
|
||
.map (question => [question.id, question]))
|
||
setAskedQuestionBank (
|
||
storedAskedQuestionBankIds
|
||
.map (questionId => questionById.get (questionId))
|
||
.filter ((question): question is GekanatorQuestion => question !== undefined))
|
||
setStoredAskedQuestionBankIds ([])
|
||
}, [
|
||
posts,
|
||
storedAskedQuestionBankIds,
|
||
acceptedQuestionsFetched,
|
||
askedQuestionBank,
|
||
acceptedQuestions])
|
||
|
||
useEffect (() => {
|
||
if (restorePromptVisible)
|
||
return
|
||
|
||
if (!(isStoredPhase (phase)) && answers.length === 0)
|
||
{
|
||
clearStoredGame ()
|
||
return
|
||
}
|
||
|
||
const stored: StoredGekanatorGame = {
|
||
phase,
|
||
scores: [...scores.entries ()],
|
||
answers,
|
||
askedIds: [...askedIds],
|
||
softenedQuestionIds: [...softenedQuestionIds],
|
||
recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts),
|
||
recoveryStepCount,
|
||
askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
|
||
askedQuestionBankIds: storedAskedQuestionBankIds,
|
||
search,
|
||
selectingCorrectPost,
|
||
saved,
|
||
resultWon,
|
||
rejectedPostIds: [...rejectedPostIds],
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount,
|
||
guessReason,
|
||
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,
|
||
recoveredCandidatePosts,
|
||
recoveryStepCount,
|
||
askedQuestionBank,
|
||
storedAskedQuestionBankIds,
|
||
search,
|
||
selectingCorrectPost,
|
||
saved,
|
||
resultWon,
|
||
rejectedPostIds,
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount,
|
||
guessReason,
|
||
activeGuessId,
|
||
reviewGuessedPostId,
|
||
reviewCorrectPostId,
|
||
savedGameId,
|
||
gameSeed,
|
||
questionSuggestion,
|
||
questionSuggestionAnswer,
|
||
questionSuggestionCount,
|
||
extraQuestions,
|
||
extraQuestionAnswers,
|
||
extraQuestionState,
|
||
restorePromptVisible])
|
||
|
||
useEffect (() => {
|
||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||
return
|
||
|
||
const media = window.matchMedia ('(prefers-reduced-motion: reduce)')
|
||
const sync = () => setPrefersReducedMotion (media.matches)
|
||
|
||
sync ()
|
||
media.addEventListener ('change', sync)
|
||
return () => media.removeEventListener ('change', sync)
|
||
}, [])
|
||
|
||
useEffect (() => {
|
||
try
|
||
{
|
||
localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode)
|
||
}
|
||
catch
|
||
{
|
||
return
|
||
}
|
||
}, [backgroundMotionMode])
|
||
|
||
useEffect (() => {
|
||
try
|
||
{
|
||
localStorage.setItem (performanceModeStorageKey, performanceMode)
|
||
}
|
||
catch
|
||
{
|
||
return
|
||
}
|
||
}, [performanceMode])
|
||
|
||
const askedQuestionById = useMemo (
|
||
() => new Map (askedQuestionBank.map (question => [question.id, question])),
|
||
[askedQuestionBank])
|
||
const eligiblePosts = useMemo (
|
||
() => candidatePostsForState ({
|
||
posts,
|
||
questionById: askedQuestionById,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
answers,
|
||
softenedQuestionIds,
|
||
rejectedPostIds,
|
||
recoveredCandidatePosts }),
|
||
[posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex,
|
||
answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts])
|
||
const scoringQuestions = useMemo (() => {
|
||
return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank])
|
||
}, [acceptedQuestions, askedQuestionBank])
|
||
const scoringQuestionById = useMemo (
|
||
() => new Map (scoringQuestions.map (question => [question.id, question])),
|
||
[scoringQuestions])
|
||
const recentFirstQuestionPenaltyById = useMemo (() => {
|
||
if (performanceMode === 'lite')
|
||
return new Map<string, number> ()
|
||
|
||
const penalties = new Map<string, number> ()
|
||
|
||
recentGames.forEach ((game, index) => {
|
||
if (!(game.firstQuestionId))
|
||
return
|
||
|
||
penalties.set (
|
||
game.firstQuestionId,
|
||
(penalties.get (game.firstQuestionId) ?? 0) + Math.max (.2, 1 - index * .22))
|
||
})
|
||
|
||
return penalties
|
||
}, [performanceMode, recentGames])
|
||
const userPriorWeights = useMemo (
|
||
() => performanceMode === 'lite'
|
||
? new Map<number, number> ()
|
||
: userPriorWeightsFor (posts, recentGames),
|
||
[performanceMode, posts, recentGames])
|
||
const availablePosts = useMemo (
|
||
() => posts.filter (post => !(rejectedPostIds.has (post.id))),
|
||
[posts, rejectedPostIds])
|
||
const questionPlan = useMemo (
|
||
() => nextQuestionPlanFor ({
|
||
posts,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }),
|
||
[posts, eligiblePosts, availablePosts, acceptedQuestions, scores,
|
||
answers, askedIds, gameSeed, recentFirstQuestionPenaltyById,
|
||
userPriorWeights, performanceMode, materialIndex, acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount])
|
||
const winningRunTargetPost = useMemo (
|
||
() => questionPlan.winningRunTargetId === null
|
||
? null
|
||
: posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null,
|
||
[posts, questionPlan.winningRunTargetId])
|
||
const winningRunQuestionsAsked = winningRunQuestionCount (
|
||
answers,
|
||
questionPlan.winningRunStartAnswerCount)
|
||
const winningRunActive =
|
||
isWinningRunActive (
|
||
questionPlan.winningRunTargetId,
|
||
questionPlan.winningRunStartAnswerCount)
|
||
&& winningRunQuestionsAsked < winningRunQuestionLimit
|
||
&& eligiblePosts.length === 1
|
||
&& eligiblePosts[0]?.id === questionPlan.winningRunTargetId
|
||
&& winningRunTargetPost !== null
|
||
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 = questionPlan.question
|
||
const answerPreviews = useMemo (
|
||
() => isAdmin && currentQuestion
|
||
? answerOptions.map (option => previewAnswer ({
|
||
posts: eligiblePosts,
|
||
scores,
|
||
question: currentQuestion,
|
||
answer: option.value,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex }))
|
||
: [],
|
||
[isAdmin, currentQuestion, eligiblePosts, materialIndex,
|
||
acceptedQuestionMatchIndex, scores])
|
||
const guessablePosts =
|
||
eligiblePosts.length > 0
|
||
? eligiblePosts
|
||
: availablePosts
|
||
const guessConfidences = useMemo (
|
||
() => confidencesFor (guessablePosts, scores),
|
||
[guessablePosts, scores])
|
||
const bestConfidencePercent = guessConfidences[0]?.percent ?? 0
|
||
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 effectiveResultWon =
|
||
resultWon ?? (
|
||
reviewGuessedPostId !== null
|
||
&& reviewCorrectPostId !== null
|
||
? reviewGuessedPostId === reviewCorrectPostId
|
||
: null)
|
||
const effectiveBackgroundMotionMode =
|
||
performanceMode === 'lite'
|
||
? 'off'
|
||
: backgroundMotionMode === 'off'
|
||
? 'off'
|
||
: prefersReducedMotion
|
||
? 'calm'
|
||
: backgroundMotionMode
|
||
const backgroundPosts = useMemo (
|
||
() => performanceMode === 'lite'
|
||
? []
|
||
: backgroundPostsFor ({
|
||
phase,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
displayedGuess,
|
||
reviewCorrectPost,
|
||
reviewGuessedPost }),
|
||
[performanceMode, phase, eligiblePosts, availablePosts, displayedGuess,
|
||
reviewCorrectPost, reviewGuessedPost])
|
||
const backgroundVisualSeed =
|
||
performanceMode === 'lite'
|
||
? ''
|
||
: `${ gameSeed }:${ phase }:${ answers.length }:${ activeGuessId ?? '' }:${
|
||
questionPlan.question?.id ?? ''
|
||
}:${ questionPlan.questionMode ?? '' }:${ winningRunQuestionsAsked }:${
|
||
rejectedPostIds.size
|
||
}:${ backgroundPosts.slice (0, 8).map (post => post.id).join ('|') }`
|
||
const mascot = mascotStateFor (phase, effectiveResultWon, eligiblePosts.length,
|
||
bestConfidencePercent, winningRunActive)
|
||
const mascotAsset = mascotAssetByState[mascot]
|
||
const mascotAlt = mascotAltByState[mascot]
|
||
const saveMutation = useMutation ({
|
||
mutationFn: saveGekanatorGame,
|
||
onSuccess: (data, variables) => {
|
||
setRecentGames (storeRecentGameSummary ({
|
||
correctPostId: variables.correctPostId,
|
||
firstQuestionId: variables.answers[0]?.questionId ?? null,
|
||
savedAt: Date.now () }))
|
||
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 ('end')
|
||
}})
|
||
|
||
const resetExtraQuestionState = () => {
|
||
const next = resettableExtraQuestionState ()
|
||
setExtraQuestions (next.extraQuestions)
|
||
setExtraQuestionAnswers (next.extraQuestionAnswers)
|
||
setExtraQuestionState (next.extraQuestionState)
|
||
extraQuestionAnswersMutation.reset ()
|
||
}
|
||
|
||
const reset = () => {
|
||
clearStoredGame ()
|
||
saveMutation.reset ()
|
||
questionSuggestionMutation.reset ()
|
||
setRestorePromptVisible (false)
|
||
setPhase ('intro')
|
||
setScores (new Map ())
|
||
setAnswers ([])
|
||
setAskedIds (new Set ())
|
||
setSoftenedQuestionIds (new Set ())
|
||
setRecoveredCandidatePosts (new Map ())
|
||
setRecoveryStepCount (0)
|
||
setAskedQuestionBank ([])
|
||
setSearch ('')
|
||
setSelectingCorrectPost (false)
|
||
setSaved (false)
|
||
setResultWon (null)
|
||
setRejectedPostIds (new Set ())
|
||
setLastGuessQuestionCount (0)
|
||
setLastRejectedGuessId (null)
|
||
setWinningRunTargetId (null)
|
||
setWinningRunStartAnswerCount (null)
|
||
setGuessReason (null)
|
||
setActiveGuessId (null)
|
||
setReviewGuessedPostId (null)
|
||
setReviewCorrectPostId (null)
|
||
setSavedGameId (null)
|
||
setGameSeed (createGameSeed ())
|
||
setQuestionSuggestion ('')
|
||
setQuestionSuggestionAnswer ('yes')
|
||
setQuestionSuggestionCount (0)
|
||
resetExtraQuestionState ()
|
||
setHistory ([])
|
||
}
|
||
|
||
const continueStoredGame = () => {
|
||
setRestorePromptVisible (false)
|
||
setPhase (storedGame?.phase ?? 'question')
|
||
}
|
||
|
||
const recoverQuestionState = useCallback (({
|
||
nextAnswers,
|
||
nextAskedIds,
|
||
nextAskedQuestionBank,
|
||
nextSoftenedQuestionIds,
|
||
nextRejectedPostIds,
|
||
nextRecoveredCandidatePosts,
|
||
nextRecoveryStepCount,
|
||
allowPreQuestionRecovery,
|
||
}: {
|
||
nextAnswers: GekanatorAnswerLog[]
|
||
nextAskedIds: Set<string>
|
||
nextAskedQuestionBank: GekanatorQuestion[]
|
||
nextSoftenedQuestionIds: Set<string>
|
||
nextRejectedPostIds: Set<number>
|
||
nextRecoveredCandidatePosts: Map<number, number>
|
||
nextRecoveryStepCount: number
|
||
allowPreQuestionRecovery?: boolean
|
||
}) => {
|
||
let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
|
||
let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts)
|
||
let recoveredStepCount = nextRecoveryStepCount
|
||
const nextAskedQuestionById =
|
||
new Map (nextAskedQuestionBank.map (question => [question.id, question]))
|
||
const answerCountAtRecovery =
|
||
allowPreQuestionRecovery
|
||
? nextAnswers.length
|
||
: Math.max (nextAnswers.length - 1, 0)
|
||
let recoveredScores = recalculateScores ({
|
||
posts,
|
||
questions: nextAskedQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex })
|
||
let recoveredEligiblePosts = candidatePostsForState ({
|
||
posts,
|
||
questionById: nextAskedQuestionById,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
rejectedPostIds: nextRejectedPostIds,
|
||
recoveredCandidatePosts })
|
||
let recoveredQuestions = buildQuestionsForCandidateIds ({
|
||
candidateIds: recoveredEligiblePosts.map (post => post.id),
|
||
materialIndex,
|
||
performanceMode,
|
||
acceptedQuestions })
|
||
let recoveredScoringQuestions = mergeQuestions ([
|
||
...recoveredQuestions,
|
||
...nextAskedQuestionBank])
|
||
|
||
const refreshRecoveredState = () => {
|
||
recoveredScores = recalculateScores ({
|
||
posts,
|
||
questions: nextAskedQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex })
|
||
recoveredEligiblePosts = candidatePostsForState ({
|
||
posts,
|
||
questionById: nextAskedQuestionById,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
rejectedPostIds: nextRejectedPostIds,
|
||
recoveredCandidatePosts })
|
||
recoveredQuestions = buildQuestionsForCandidateIds ({
|
||
candidateIds: recoveredEligiblePosts.map (post => post.id),
|
||
materialIndex,
|
||
performanceMode,
|
||
acceptedQuestions })
|
||
recoveredScoringQuestions = mergeQuestions ([
|
||
...recoveredQuestions,
|
||
...nextAskedQuestionBank])
|
||
}
|
||
|
||
const needsPreQuestionRecovery = () => {
|
||
if (
|
||
!(allowPreQuestionRecovery)
|
||
|| recoveredEligiblePosts.length === 0
|
||
|| recoveredEligiblePosts.length === 1
|
||
)
|
||
return false
|
||
|
||
const nextQuestion = chooseQuestion ({
|
||
posts: recoveredEligiblePosts,
|
||
questions: recoveredScoringQuestions,
|
||
scores: recoveredScores,
|
||
answers: nextAnswers,
|
||
askedIds: nextAskedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex })
|
||
const fallbackQuestion = nextQuestion ?? chooseFallbackQuestion ({
|
||
posts: recoveredEligiblePosts,
|
||
allPosts: posts,
|
||
questions: recoveredScoringQuestions,
|
||
answers: nextAnswers,
|
||
askedIds: nextAskedIds,
|
||
scores: recoveredScores,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex })
|
||
|
||
return !(fallbackQuestion)
|
||
|| !(hasDiscriminatingHardSplitForQuestion ({
|
||
candidateIds: recoveredEligiblePosts.map (post => post.id),
|
||
question: fallbackQuestion,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex }))
|
||
}
|
||
|
||
while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ())
|
||
{
|
||
const recoveredPosts = recoverCandidatePosts ({
|
||
posts,
|
||
scores: recoveredScores,
|
||
rejectedPostIds: nextRejectedPostIds,
|
||
recoveredCandidatePosts,
|
||
eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)),
|
||
answerCountAtRecovery,
|
||
recoveryStepCount: recoveredStepCount })
|
||
if (recoveredPosts)
|
||
{
|
||
recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts
|
||
recoveredStepCount = recoveredPosts.recoveryStepCount
|
||
refreshRecoveredState ()
|
||
if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
|
||
break
|
||
}
|
||
|
||
if (nextAnswers.length >= hardMaxQuestions)
|
||
break
|
||
|
||
if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
|
||
break
|
||
|
||
const softened = softenNextQuestionIds ({
|
||
questions: nextAskedQuestionBank,
|
||
answers: nextAnswers,
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds })
|
||
if (!(softened))
|
||
break
|
||
|
||
recoveredSoftenedQuestionIds = softened
|
||
refreshRecoveredState ()
|
||
}
|
||
|
||
return {
|
||
softenedQuestionIds: recoveredSoftenedQuestionIds,
|
||
recoveredCandidatePosts,
|
||
recoveryStepCount: recoveredStepCount,
|
||
scores: recoveredScores,
|
||
eligiblePosts: recoveredEligiblePosts,
|
||
scoringQuestions: recoveredScoringQuestions }
|
||
}, [
|
||
posts,
|
||
gameSeed,
|
||
performanceMode,
|
||
materialIndex,
|
||
acceptedQuestions,
|
||
acceptedQuestionMatchIndex,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights])
|
||
|
||
const answer = (value: GekanatorAnswerValue) => {
|
||
if (!(currentQuestion))
|
||
{
|
||
if (questionPlan.guess && shouldEnterGuessPhase (questionPlan.guessReason))
|
||
{
|
||
setActiveGuessId (questionPlan.guess.id)
|
||
setLastGuessQuestionCount (answers.length)
|
||
setGuessReason (questionPlan.guessReason)
|
||
setPhase ('guess')
|
||
}
|
||
return
|
||
}
|
||
|
||
setHistory ([...history, {
|
||
phase,
|
||
scores: new Map (scores),
|
||
answers: [...answers],
|
||
askedIds: new Set (askedIds),
|
||
softenedQuestionIds: new Set (softenedQuestionIds),
|
||
recoveredCandidatePosts: new Map (recoveredCandidatePosts),
|
||
recoveryStepCount,
|
||
askedQuestionBank: [...askedQuestionBank],
|
||
search,
|
||
selectingCorrectPost,
|
||
rejectedPostIds: new Set (rejectedPostIds),
|
||
lastGuessQuestionCount,
|
||
lastRejectedGuessId,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount,
|
||
guessReason,
|
||
activeGuessId,
|
||
reviewGuessedPostId,
|
||
reviewCorrectPostId }])
|
||
const nextAnswers = [...answers, {
|
||
questionId: currentQuestion.id,
|
||
questionText: currentQuestion.text,
|
||
questionCondition: currentQuestion.condition,
|
||
questionMode: questionPlan.questionMode ?? undefined,
|
||
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,
|
||
nextRecoveredCandidatePosts: recoveredCandidatePosts,
|
||
nextRecoveryStepCount: recoveryStepCount })
|
||
const nextEligiblePosts = recovered.eligiblePosts
|
||
let nextPlan = nextQuestionPlanFor ({
|
||
posts,
|
||
eligiblePosts: nextEligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores: recovered.scores,
|
||
answers: nextAnswers,
|
||
askedIds: nextAskedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount })
|
||
let finalRecovered = recovered
|
||
|
||
if (
|
||
!(nextPlan.question)
|
||
&& !(shouldEnterGuessPhase (nextPlan.guessReason))
|
||
&& recovered.eligiblePosts.length !== 1
|
||
)
|
||
{
|
||
const recoveredForQuestion = recoverQuestionState ({
|
||
nextAnswers,
|
||
nextAskedIds,
|
||
nextAskedQuestionBank,
|
||
nextSoftenedQuestionIds: recovered.softenedQuestionIds,
|
||
nextRejectedPostIds: rejectedPostIds,
|
||
nextRecoveredCandidatePosts: recovered.recoveredCandidatePosts,
|
||
nextRecoveryStepCount: recovered.recoveryStepCount,
|
||
allowPreQuestionRecovery: true })
|
||
nextPlan = nextQuestionPlanFor ({
|
||
posts,
|
||
eligiblePosts: recoveredForQuestion.eligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores: recoveredForQuestion.scores,
|
||
answers: nextAnswers,
|
||
askedIds: nextAskedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount })
|
||
finalRecovered = recoveredForQuestion
|
||
}
|
||
|
||
setScores (finalRecovered.scores)
|
||
setAskedIds (nextAskedIds)
|
||
setSoftenedQuestionIds (finalRecovered.softenedQuestionIds)
|
||
setRecoveredCandidatePosts (finalRecovered.recoveredCandidatePosts)
|
||
setRecoveryStepCount (finalRecovered.recoveryStepCount)
|
||
setAskedQuestionBank (nextAskedQuestionBank)
|
||
setAnswers (nextAnswers)
|
||
setWinningRunTargetId (nextPlan.winningRunTargetId)
|
||
setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount)
|
||
|
||
if (nextPlan.question)
|
||
{
|
||
setGuessReason (null)
|
||
setActiveGuessId (null)
|
||
setPhase ('question')
|
||
return
|
||
}
|
||
|
||
setGuessReason (nextPlan.guessReason)
|
||
if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason))
|
||
{
|
||
setActiveGuessId (nextPlan.guess.id)
|
||
setLastGuessQuestionCount (nextAnswers.length)
|
||
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 (
|
||
!(canPersistGame)
|
||
|| 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 = () => {
|
||
if (!(canPersistGame))
|
||
{
|
||
reset ()
|
||
return
|
||
}
|
||
|
||
saveReviewedResult (reset)
|
||
}
|
||
|
||
const saveAndLearn = () => {
|
||
if (!(canPersistGame))
|
||
{
|
||
setPhase ('end')
|
||
return
|
||
}
|
||
|
||
resetExtraQuestionState ()
|
||
saveReviewedResult (() => setPhase ('end'))
|
||
}
|
||
|
||
const submitQuestionSuggestion = () => {
|
||
const questionText = questionSuggestion.trim ()
|
||
if (
|
||
!(canPersistGame)
|
||
|| !(questionText)
|
||
|| questionSuggestionMutation.isPending
|
||
|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||
)
|
||
return
|
||
|
||
saveReviewedResult (gekanatorGameId => {
|
||
questionSuggestionMutation.mutate ({
|
||
gekanatorGameId,
|
||
questionText,
|
||
answer: questionSuggestionAnswer })
|
||
})
|
||
}
|
||
|
||
const saveExtraQuestions = () => {
|
||
if (
|
||
!(canPersistGame)
|
||
|| 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 rejectGuess = () => {
|
||
if (!(displayedGuess))
|
||
return
|
||
|
||
setLastRejectedGuessId (displayedGuess.id)
|
||
if (answers.length >= hardMaxQuestions)
|
||
{
|
||
setSelectingCorrectPost (true)
|
||
return
|
||
}
|
||
|
||
setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
|
||
setRecoveredCandidatePosts (
|
||
new Map (
|
||
[...recoveredCandidatePosts.entries ()].filter (
|
||
([postId]) => postId !== displayedGuess.id)))
|
||
setWinningRunTargetId (null)
|
||
setWinningRunStartAnswerCount (null)
|
||
setGuessReason (null)
|
||
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)
|
||
setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts)
|
||
setRecoveryStepCount (snapshot.recoveryStepCount)
|
||
setAskedQuestionBank (snapshot.askedQuestionBank)
|
||
setSearch (snapshot.search)
|
||
setSelectingCorrectPost (snapshot.selectingCorrectPost)
|
||
setRejectedPostIds (snapshot.rejectedPostIds)
|
||
setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
|
||
setLastRejectedGuessId (snapshot.lastRejectedGuessId)
|
||
setWinningRunTargetId (snapshot.winningRunTargetId)
|
||
setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount)
|
||
setGuessReason (snapshot.guessReason)
|
||
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,
|
||
nextRecoveredCandidatePosts: recoveredCandidatePosts,
|
||
nextRecoveryStepCount: recoveryStepCount,
|
||
allowPreQuestionRecovery: true })
|
||
|
||
setSoftenedQuestionIds (recovered.softenedQuestionIds)
|
||
setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
|
||
setRecoveryStepCount (recovered.recoveryStepCount)
|
||
setScores (recovered.scores)
|
||
const nextPlan = nextQuestionPlanFor ({
|
||
posts,
|
||
eligiblePosts: recovered.eligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores: recovered.scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
performanceMode,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount })
|
||
|
||
setWinningRunTargetId (nextPlan.winningRunTargetId)
|
||
setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount)
|
||
|
||
if (nextPlan.question)
|
||
{
|
||
setGuessReason (null)
|
||
setActiveGuessId (null)
|
||
setPhase ('question')
|
||
return
|
||
}
|
||
|
||
setGuessReason (nextPlan.guessReason)
|
||
if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason))
|
||
{
|
||
setActiveGuessId (nextPlan.guess.id)
|
||
setLastGuessQuestionCount (answers.length)
|
||
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')
|
||
const nonce = createGameSeed ()
|
||
|
||
try
|
||
{
|
||
const questions = await queryClient.fetchQuery ({
|
||
queryKey: gekanatorKeys.extraQuestions (gameId, nonce),
|
||
queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) })
|
||
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 introDialogue =
|
||
<>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>。質問から投稿を何でも当ててみせるよ。</>
|
||
|
||
const winDialogue =
|
||
<>グカカカカwwwww <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
|
||
|
||
const loseDialogue =
|
||
<>ぬわーん! <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>外しちゃったグカー!!!!!</>
|
||
|
||
const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue
|
||
|
||
const dialogue = phase === 'learned' ? resultDialogue : introDialogue
|
||
|
||
const introLoading = isLoading || acceptedQuestionsLoading
|
||
const readyToStart =
|
||
!(introLoading)
|
||
&& acceptedQuestionsFetched
|
||
&& posts.length > 0
|
||
&& !(error)
|
||
&& !(acceptedQuestionsError)
|
||
|
||
useEffect (() => {
|
||
if (
|
||
phase !== 'question'
|
||
|| currentQuestion
|
||
|| isLoading
|
||
|| acceptedQuestionsLoading
|
||
|| shouldEnterGuessPhase (questionPlan.guessReason)
|
||
|| eligiblePosts.length === 1
|
||
)
|
||
return
|
||
|
||
const recovered = recoverQuestionState ({
|
||
nextAnswers: answers,
|
||
nextAskedIds: askedIds,
|
||
nextAskedQuestionBank: askedQuestionBank,
|
||
nextSoftenedQuestionIds: softenedQuestionIds,
|
||
nextRejectedPostIds: rejectedPostIds,
|
||
nextRecoveredCandidatePosts: recoveredCandidatePosts,
|
||
nextRecoveryStepCount: recoveryStepCount,
|
||
allowPreQuestionRecovery: true })
|
||
|
||
if (
|
||
recovered.recoveryStepCount === recoveryStepCount
|
||
&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
|
||
&& recovered.softenedQuestionIds.size === softenedQuestionIds.size
|
||
)
|
||
return
|
||
|
||
setSoftenedQuestionIds (recovered.softenedQuestionIds)
|
||
setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
|
||
setRecoveryStepCount (recovered.recoveryStepCount)
|
||
setScores (recovered.scores)
|
||
}, [
|
||
phase,
|
||
currentQuestion,
|
||
questionPlan,
|
||
answers,
|
||
askedIds,
|
||
askedQuestionBank,
|
||
softenedQuestionIds,
|
||
rejectedPostIds,
|
||
recoveredCandidatePosts,
|
||
recoveryStepCount,
|
||
eligiblePosts,
|
||
recoverQuestionState,
|
||
isLoading,
|
||
acceptedQuestionsLoading])
|
||
|
||
useEffect (() => {
|
||
if (
|
||
phase !== 'question'
|
||
|| isLoading
|
||
|| acceptedQuestionsLoading
|
||
)
|
||
return
|
||
|
||
if (
|
||
currentQuestion
|
||
|| !(questionPlan.guess)
|
||
|| !(shouldEnterGuessPhase (questionPlan.guessReason))
|
||
)
|
||
return
|
||
|
||
setWinningRunTargetId (questionPlan.winningRunTargetId)
|
||
setWinningRunStartAnswerCount (questionPlan.winningRunStartAnswerCount)
|
||
setActiveGuessId (questionPlan.guess.id)
|
||
setLastGuessQuestionCount (answers.length)
|
||
setGuessReason (questionPlan.guessReason)
|
||
setPhase ('guess')
|
||
}, [
|
||
phase,
|
||
currentQuestion,
|
||
questionPlan,
|
||
answers,
|
||
isLoading,
|
||
acceptedQuestionsLoading])
|
||
|
||
return (
|
||
<MainArea className="relative isolate overflow-x-hidden bg-yellow-50 dark:bg-red-975">
|
||
<Helmet>
|
||
<title>{`グカネータ | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
{performanceMode !== 'lite' && (
|
||
<GekanatorBackdrop
|
||
posts={backgroundPosts}
|
||
mascotAsset={mascotAsset}
|
||
phase={phase}
|
||
displayedGuess={displayedGuess}
|
||
visualSeed={backgroundVisualSeed}
|
||
motionMode={effectiveBackgroundMotionMode}
|
||
winningRunTargetPost={winningRunActive ? winningRunTargetPost : null}
|
||
winningRunQuestionCount={winningRunQuestionsAsked}/>)}
|
||
|
||
<div className="relative z-10 mx-auto max-w-4xl space-y-6">
|
||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||
<div className="space-y-2">
|
||
<h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
|
||
グカネータ
|
||
</h1>
|
||
</div>
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
{performanceMode === 'normal' && (
|
||
<div className="rounded-full border border-yellow-300 bg-white/80 px-2 py-1
|
||
text-xs shadow-sm backdrop-blur dark:border-red-800
|
||
dark:bg-red-950/75">
|
||
<span className="mr-2 font-bold text-neutral-600 dark:text-neutral-300">
|
||
背景
|
||
</span>
|
||
{[{ mode: 'off' as const, label: 'オフ' },
|
||
{ mode: 'on' as const, label: 'オン' }]
|
||
.map (({ mode, label }) => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
className={cn (
|
||
'rounded-full px-2.5 py-1 transition-colors',
|
||
backgroundMotionMode === mode
|
||
? 'bg-pink-600 text-white'
|
||
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900')}
|
||
onClick={() => setBackgroundMotionMode (mode)}>
|
||
{label}
|
||
</button>))}
|
||
{prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && (
|
||
<span className="ml-2 text-[11px] text-neutral-500 dark:text-neutral-400">
|
||
端末設定により控えめ表示
|
||
</span>)}
|
||
</div>)}
|
||
</div>
|
||
</header>
|
||
|
||
<section className="relative z-10 rounded-lg border border-yellow-300
|
||
bg-white/90 p-4 shadow-sm backdrop-blur-sm
|
||
dark:border-red-800 dark:bg-red-950/90">
|
||
<div className="relative z-10 flex gap-4">
|
||
<div className="shrink-0 space-y-2">
|
||
<div className="overflow-hidden rounded-[1.4rem] border border-white/70
|
||
bg-white/75 shadow-lg backdrop-blur dark:border-red-900/80
|
||
dark:bg-red-950/70">
|
||
<img
|
||
src={mascotAsset}
|
||
alt={mascotAlt}
|
||
className="h-28 w-28 object-cover md:h-32 md:w-32"/>
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
{phase === 'intro' && (
|
||
<p className="text-lg font-bold">
|
||
{dialogue}
|
||
</p>)}
|
||
|
||
{introLoading && (
|
||
<p>
|
||
{phase === 'intro'
|
||
? '投稿を読み込んでいます……'
|
||
: '前回のグカネータ状態を復元しています……'}
|
||
</p>)}
|
||
{(Boolean (error) || Boolean (acceptedQuestionsError))
|
||
&& <p>グカネータの質問データを読み込めませんでした.</p>}
|
||
|
||
{phase === 'intro' && readyToStart && restorePromptVisible && (
|
||
<div className="flex flex-wrap gap-2">
|
||
<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={() => {
|
||
reset ()
|
||
setRestorePromptVisible (false)
|
||
setPhase ('question')
|
||
}}>
|
||
最初からやり直す
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={continueStoredGame}>
|
||
続きから
|
||
</button>
|
||
</div>)}
|
||
|
||
{phase === 'intro' && readyToStart && !(restorePromptVisible) && (
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500"
|
||
onClick={() => {
|
||
setRestorePromptVisible (false)
|
||
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>
|
||
{isAdmin && (
|
||
<div className="rounded border border-yellow-100 px-3 py-2
|
||
text-sm dark:border-red-900">
|
||
<div className="font-bold">現在候補: {eligiblePosts.length} 件</div>
|
||
<div className="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
|
||
winningRunTargetId: {String (questionPlan.winningRunTargetId)}
|
||
{' / '}
|
||
winningRunQuestionCount: {winningRunQuestionsAsked}
|
||
{' / '}
|
||
guessReason: {guessReason ?? '-'}
|
||
{' / '}
|
||
questionMode: {questionPlan.questionMode ?? '-'}
|
||
{' / '}
|
||
recoveryStepCount: {recoveryStepCount}
|
||
{' / '}
|
||
currentQuestion===null: {String (currentQuestion === null)}
|
||
</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>)}
|
||
{isAdmin && 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 className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||
effective {preview?.effectiveCandidates.toFixed (2) ?? '0.00'}
|
||
{' / '}
|
||
entropy {preview?.entropy.toFixed (2) ?? '0.00'}
|
||
</div>
|
||
</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>)}
|
||
{phase === 'question' && !(currentQuestion) && isAdmin && (
|
||
<div className="rounded border border-yellow-100 px-3 py-2 text-sm
|
||
dark:border-red-900">
|
||
<div className="font-bold">question stalled</div>
|
||
<div className="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
|
||
winningRunTargetId: {String (questionPlan.winningRunTargetId)}
|
||
{' / '}
|
||
winningRunQuestionCount: {winningRunQuestionsAsked}
|
||
{' / '}
|
||
guessReason: {questionPlan.guessReason ?? '-'}
|
||
{' / '}
|
||
questionMode: {questionPlan.questionMode ?? '-'}
|
||
{' / '}
|
||
recoveryStepCount: {recoveryStepCount}
|
||
{' / '}
|
||
candidateCount: {eligiblePosts.length}
|
||
</div>
|
||
</div>)}
|
||
{phase === 'guess' && displayedGuess && (
|
||
<div className="space-y-4">
|
||
<p className="text-xl font-bold">思い浮かべているのは、これだね?</p>
|
||
{isAdmin && (
|
||
<div className="rounded border border-yellow-100 px-3 py-2
|
||
text-sm dark:border-red-900">
|
||
winningRunTargetId: {String (questionPlan.winningRunTargetId)}
|
||
{' / '}
|
||
winningRunQuestionCount: {winningRunQuestionsAsked}
|
||
{' / '}
|
||
guessReason: {guessReason ?? '-'}
|
||
{' / '}
|
||
questionMode: {questionPlan.questionMode ?? '-'}
|
||
{' / '}
|
||
recoveryStepCount: {recoveryStepCount}
|
||
{' / '}
|
||
currentQuestion===null: {String (currentQuestion === null)}
|
||
</div>)}
|
||
<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-xl font-bold">{resultDialogue}</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>)}
|
||
{!(canPersistGame) && (
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
未ログインのため今回の結果は保存されません。
|
||
<PrefetchLink to="/users/settings" className="ml-1 underline">
|
||
設定
|
||
</PrefetchLink>
|
||
から引継ぎコードを復元すると、質問追加や追加学習も使へます。
|
||
</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:opacity-50"
|
||
disabled={
|
||
!(canPersistGame)
|
||
|| 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:opacity-50"
|
||
disabled={!(canPersistGame)
|
||
|| 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={!(canPersistGame)
|
||
|| reviewCorrectPostId === null
|
||
|| saveMutation.isPending
|
||
|| extraQuestionState === 'loading'
|
||
|| extraQuestionAnswersMutation.isPending}
|
||
onClick={startExtraQuestions}>
|
||
追加で質問に答える
|
||
</button>
|
||
</div>
|
||
</div>)}
|
||
|
||
{phase === 'review' && (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<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 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>
|
||
<button
|
||
type="button"
|
||
className="rounded bg-pink-600 px-4 py-2 font-bold text-white
|
||
hover:bg-pink-500 disabled:opacity-50"
|
||
disabled={
|
||
!(canPersistGame)
|
||
|| reviewCorrectPostId === null
|
||
|| saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending
|
||
}
|
||
onClick={saveAndLearn}>
|
||
完了
|
||
</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:opacity-50"
|
||
disabled={!(canPersistGame)
|
||
|| 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={
|
||
!(canPersistGame)
|
||
||
|
||
questionSuggestionCount >= maxQuestionSuggestionsPerGame
|
||
|| reviewCorrectPostId === null
|
||
|| questionSuggestion.trim () === ''
|
||
|| saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending
|
||
}
|
||
onClick={submitQuestionSuggestion}>
|
||
追加
|
||
</button>
|
||
</div>
|
||
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
このゲームでは質問候補をこれ以上追加できません。
|
||
</p>)}
|
||
{!(canPersistGame) && (
|
||
<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={
|
||
!(canPersistGame)
|
||
||
|
||
extraQuestionState !== 'ready'
|
||
|| extraQuestionAnswersMutation.isPending
|
||
|| extraQuestions.some (
|
||
question => !(extraQuestionAnswers[String (question.id)]))
|
||
}
|
||
onClick={saveExtraQuestions}>
|
||
送信
|
||
</button>
|
||
</div>
|
||
{!(canPersistGame) && (
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
未ログインのため追加学習は保存されません。
|
||
</p>)}
|
||
</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
|