776dea87d9
開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。
## 0. 先に方針
**やらないこと:**
```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```
これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。
---
## 1. migration 確認
まず現在の状態を見る。
```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```
その後、通常 migration。
```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```
見るポイント:
```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```
確認用:
```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```
ここで `materials without versions=0` になれば、backfill は通っています。
---
## 2. 既存素材一覧の画面確認
フロントを起動して `/materials` を見る。
```sh id="vl28jd"
cd frontend
npm run dev
```
見る観点:
```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```
ここでまず、普通の素材一覧が壊れていないことを確認します。
---
## 3. 左タグバーの確認
`/materials` を開いて左タグバーからタグを選ぶ。
見る観点:
```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```
特に重要なのはこれ。
```txt id="d33os0"
親タグ A を選択
A に直接紐づく素材
A > B に紐づく素材
A > B > C に紐づく素材
が同じ一覧に出ること
```
---
## 4. 選択タグから素材追加
左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。
見る観点:
```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```
Rails console でも確認できます。
```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```
---
## 5. グループ見出しから素材追加
親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。
見る観点:
```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```
ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。
---
## 6. 素材更新と履歴
既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。
見る観点:
```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```
console:
```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
[v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```
---
## 7. サムネイル
画像素材を追加して、一覧にサムネイルが出るか確認。
動画素材があるなら、`ffmpeg` が入っている環境で backfill。
```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```
見る観点:
```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```
---
## 8. ZIP export
export path がある素材を用意して、ブラウザで確認。
```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```
見る観点:
```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```
---
## 9. 抑止
`/materials/suppressions` で path prefix 抑止を追加する。
見る観点:
```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```
---
## 10. Google Drive 同期
開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿
必要な ENV:
```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```
seed で source 作成、または console で作成。
```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```
見る観点:
```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```
---
## 11. schema.rb は別途確認
これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。
```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```
ここが残るなら、機能テストが通っても merge は止めた方がいいです。
---
## 最小テスト順
時間がないなら、この順で十分です。
```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```
これで、今回の差分の主要な導線はほぼ踏めます。
Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
5691 行
173 KiB
TypeScript
5691 行
173 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,
|
||
isLearnedSemanticQuestion,
|
||
learnedSemanticSideForPost,
|
||
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 { Transition } from 'framer-motion'
|
||
|
||
import type { GekanatorAnswerLog,
|
||
GekanatorAnswerValue,
|
||
GekanatorExtraQuestion,
|
||
GekanatorQuestionPurpose,
|
||
GekanatorQuestionCondition,
|
||
GekanatorQuestionKind,
|
||
GekanatorQuestion,
|
||
StoredGekanatorQuestion } from '@/lib/gekanator'
|
||
import type {
|
||
RecoveredCandidatePost,
|
||
RecoveredCandidateState,
|
||
} 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, RecoveredCandidateState>
|
||
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
|
||
learnedExampleCount?: number | null
|
||
gameSeed?: string
|
||
questionSuggestionEntryMode?: 'search' | 'new'
|
||
questionSuggestionSearch?: string
|
||
questionSuggestionSelectedId?: number | null
|
||
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 QuestionSelection = {
|
||
question: GekanatorQuestion
|
||
questionPurpose: GekanatorQuestionPurpose
|
||
effectiveQuestion: boolean
|
||
learningQuestion: boolean }
|
||
|
||
type QuestionBuildMode =
|
||
| 'split'
|
||
| 'confirmation'
|
||
|
||
type QuestionSuggestionEntryMode =
|
||
| 'search'
|
||
| 'new'
|
||
|
||
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 maxStoredRecentGames = 12
|
||
const specialOriginalMonthDayLabels: Record<string, string> = {
|
||
'1-1': '元日',
|
||
'12-31': '大晦日',
|
||
'12-3': '12月3日',
|
||
'5-29': '5月29日' }
|
||
const mascotAssetByState: Record<MascotState, string> = {
|
||
idle: '/assets/gekanator/mascot-idle.png',
|
||
thinking_far: '/assets/gekanator/mascot-thinking-far.png',
|
||
thinking_mid: '/assets/gekanator/mascot-thinking-mid.png',
|
||
thinking_near: '/assets/gekanator/mascot-thinking-near.png',
|
||
confident: '/assets/gekanator/mascot-confident.png',
|
||
celebrate: '/assets/gekanator/mascot-celebrate.png',
|
||
failed: '/assets/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 => {
|
||
const questionMode =
|
||
answer.questionMode === 'winning_run' || answer.questionMode === 'normal'
|
||
? answer.questionMode
|
||
: undefined
|
||
const questionCondition =
|
||
answer.questionCondition
|
||
? normalizeTitleLengthCondition (answer.questionCondition)
|
||
: undefined
|
||
|
||
return {
|
||
...answer,
|
||
questionId: normalizeStoredQuestionId (answer.questionId,
|
||
answer.questionCondition),
|
||
questionMode,
|
||
questionCondition }
|
||
}),
|
||
askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
|
||
softenedQuestionIds: (game.softenedQuestionIds
|
||
.map (questionId => normalizeStoredQuestionId (questionId))),
|
||
recoveredCandidatePosts: (game.recoveredCandidatePosts ?? []).map (item => ({
|
||
...item,
|
||
scoreAtRecovery: item.scoreAtRecovery ?? (
|
||
new Map (game.scores).get (item.postId) ?? 0) })),
|
||
recoveryStepCount: game.recoveryStepCount ?? 0,
|
||
winningRunTargetId: game.winningRunTargetId ?? null,
|
||
winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
|
||
learnedExampleCount: game.learnedExampleCount ?? null,
|
||
questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search',
|
||
questionSuggestionSearch: game.questionSuggestionSearch ?? '',
|
||
questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? 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 = 2_166_136_261
|
||
|
||
for (let i = 0; i < value.length; ++i)
|
||
{
|
||
hash ^= value.charCodeAt (i)
|
||
hash = Math.imul (hash, 16_777_619)
|
||
}
|
||
|
||
return hash >>> 0
|
||
}
|
||
|
||
|
||
const deterministicUnitFloat = (seed: string): number => hashString (seed) / 4_294_967_295
|
||
|
||
|
||
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 = (): BackgroundMotionMode => {
|
||
const fallbackMode = 'on'
|
||
try
|
||
{
|
||
const raw = localStorage.getItem (backgroundMotionStorageKey)
|
||
if (raw === 'off' || raw === 'calm' || raw === 'on')
|
||
return raw
|
||
|
||
return fallbackMode
|
||
}
|
||
catch
|
||
{
|
||
return fallbackMode
|
||
}
|
||
}
|
||
|
||
|
||
const resettableExtraQuestionState = (): {
|
||
extraQuestions: GekanatorExtraQuestion[]
|
||
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
|
||
extraQuestionState: 'idle' } => (
|
||
{ extraQuestions: [],
|
||
extraQuestionAnswers: { },
|
||
extraQuestionState: 'idle' })
|
||
|
||
|
||
const recoveredCandidateMapFromStored = (
|
||
items: RecoveredCandidatePost[],
|
||
scores: [number, number][]): Map<number, RecoveredCandidateState> => {
|
||
const storedScores = new Map (scores)
|
||
|
||
return new Map (items.map (item => [item.postId, {
|
||
answerCountAtRecovery: item.answerCountAtRecovery,
|
||
scoreAtRecovery: item.scoreAtRecovery ?? storedScores.get (item.postId) ?? 0 }]))
|
||
}
|
||
|
||
|
||
const storedRecoveredCandidatesFromMap = (
|
||
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
|
||
[...recoveredCandidatePosts.entries ()]
|
||
.map (([postId, recoveredCandidate]) => ({
|
||
postId,
|
||
answerCountAtRecovery: recoveredCandidate.answerCountAtRecovery,
|
||
scoreAtRecovery: recoveredCandidate.scoreAtRecovery }))
|
||
|
||
|
||
const learnedSemanticMinKnownRatio = .08
|
||
const learnedSemanticMinKnownCount = 4
|
||
const learnedSemanticMinSideCount = 2
|
||
const targetEffectiveUserSuggestedQuestionRatio = 1 / 3
|
||
const targetLearningUserSuggestedQuestionRatio = 1 / 3
|
||
const targetTotalUserSuggestedQuestionRatio = 2 / 3
|
||
const scoreDropActivationThreshold = 20
|
||
const learningUserSuggestedScoreWeight = .33
|
||
|
||
|
||
const baseDeltaForAnswer = (answer: GekanatorAnswerValue): number => {
|
||
switch (answer)
|
||
{
|
||
case 'yes':
|
||
return 4
|
||
case 'no':
|
||
return -4
|
||
case 'partial':
|
||
return 2
|
||
case 'probably_no':
|
||
return -2
|
||
case 'unknown':
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
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 = (() => {
|
||
if (titleLengthMinimumForCondition (question.condition) == null)
|
||
return 0
|
||
|
||
return (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 scoreWeightForAnswer = (
|
||
answer: GekanatorAnswerLog,
|
||
softenedQuestionIds: Set<string>): number =>
|
||
answerWeightFor (answer.questionId, softenedQuestionIds)
|
||
* (
|
||
answer.questionPurpose === 'learning_user_suggested'
|
||
? learningUserSuggestedScoreWeight
|
||
: 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 specialOriginalMonthDayLabelFor = (monthDay: string): string | null =>
|
||
specialOriginalMonthDayLabels[monthDay] ?? null
|
||
|
||
|
||
const originalDateQuestionTextFor = (
|
||
condition: Extract<
|
||
GekanatorQuestionCondition,
|
||
{ type: 'original-year' | 'original-month' | 'original-month-day' }
|
||
>): string => {
|
||
switch (condition.type)
|
||
{
|
||
case 'original-year':
|
||
return `オリジナルの投稿年は ${ condition.year } 年?`
|
||
case 'original-month':
|
||
return `オリジナルの投稿月は ${ condition.month } 月?`
|
||
case 'original-month-day':
|
||
{
|
||
const label = specialOriginalMonthDayLabelFor (condition.monthDay)
|
||
if (label)
|
||
return `${ label }に投稿された?`
|
||
|
||
const [month, day] = condition.monthDay.split ('-')
|
||
return `オリジナルの投稿日は ${ month } 月 ${ day } 日?`
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
const humanPriorityOffsetFor = (question: GekanatorQuestion): number => {
|
||
switch (question.kind)
|
||
{
|
||
case 'tag':
|
||
return -6
|
||
case 'source':
|
||
return -2.5
|
||
case 'post_similarity':
|
||
if (question.source === 'user_suggested' || question.source === 'admin_curated')
|
||
return -3.5
|
||
|
||
return -1.5
|
||
case 'original_date':
|
||
switch (question.condition.type)
|
||
{
|
||
case 'original-year':
|
||
return -2
|
||
case 'original-month-day':
|
||
return specialOriginalMonthDayLabelFor (question.condition.monthDay) ? -1.4 : 6
|
||
case 'original-month':
|
||
return 7
|
||
default:
|
||
return 0
|
||
}
|
||
case 'title':
|
||
switch (question.condition.type)
|
||
{
|
||
case 'title-contains':
|
||
return -1.8
|
||
case 'title-has-ascii':
|
||
return 10
|
||
case 'title-length-at-least':
|
||
case 'title-length-greater-than':
|
||
return 9
|
||
default:
|
||
return 0
|
||
}
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))
|
||
|
||
|
||
const isUserSuggestedLearnedSemanticQuestion = (
|
||
question: GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)
|
||
|
||
|
||
type LearnedSemanticCandidateStats = {
|
||
positiveIds: Set<number>
|
||
negativeIds: Set<number>
|
||
unknownIds: Set<number>
|
||
positiveCount: number
|
||
negativeCount: number
|
||
unknownCount: number
|
||
knownCount: number }
|
||
|
||
|
||
const learnedSemanticStatsForCandidateIds = (
|
||
{ candidateIds,
|
||
posts,
|
||
question }: {
|
||
candidateIds: number[]
|
||
posts: Post[]
|
||
question: GekanatorQuestion }): LearnedSemanticCandidateStats => {
|
||
const candidateIdSet = new Set (candidateIds)
|
||
const positiveIds = new Set<number> ()
|
||
const negativeIds = new Set<number> ()
|
||
const unknownIds = new Set<number> ()
|
||
|
||
posts.forEach (post => {
|
||
if (!(candidateIdSet.has (post.id)))
|
||
return
|
||
|
||
const side = learnedSemanticSideForPost (question, post)
|
||
if (side === 'positive')
|
||
{
|
||
positiveIds.add (post.id)
|
||
return
|
||
}
|
||
if (side === 'negative')
|
||
{
|
||
negativeIds.add (post.id)
|
||
return
|
||
}
|
||
|
||
unknownIds.add (post.id)
|
||
})
|
||
|
||
return {
|
||
positiveIds,
|
||
negativeIds,
|
||
unknownIds,
|
||
positiveCount: positiveIds.size,
|
||
negativeCount: negativeIds.size,
|
||
unknownCount: unknownIds.size,
|
||
knownCount: positiveIds.size + negativeIds.size }
|
||
}
|
||
|
||
|
||
const learnedSemanticQuestionIsEffectiveForCandidateIds = (
|
||
{ candidateIds,
|
||
posts,
|
||
question }: {
|
||
candidateIds: number[]
|
||
posts: Post[]
|
||
question: GekanatorQuestion }): boolean => {
|
||
if (!(isUserSuggestedLearnedSemanticQuestion (question)))
|
||
return false
|
||
|
||
const stats = learnedSemanticStatsForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
question })
|
||
const minimumKnownCount = Math.max (
|
||
learnedSemanticMinKnownCount,
|
||
Math.floor (candidateIds.length * learnedSemanticMinKnownRatio))
|
||
|
||
return stats.knownCount >= minimumKnownCount
|
||
&& stats.positiveCount >= learnedSemanticMinSideCount
|
||
&& stats.negativeCount >= learnedSemanticMinSideCount
|
||
}
|
||
|
||
|
||
const directSemanticAnswerForPost = (
|
||
question: GekanatorQuestion,
|
||
post: Post): GekanatorAnswerValue | null => {
|
||
const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
|
||
if (direct)
|
||
return direct
|
||
if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
|
||
return question.condition.answer
|
||
|
||
return null
|
||
}
|
||
|
||
|
||
const learnedSemanticLearningValueForTopPosts = (
|
||
{ question,
|
||
learningTargetPosts,
|
||
candidateIds,
|
||
posts }: {
|
||
question: GekanatorQuestion
|
||
learningTargetPosts: Post[]
|
||
candidateIds: number[]
|
||
posts: Post[] }): { missingTopCount: number
|
||
knownCount: number
|
||
hasLearningValue: boolean } => {
|
||
const missingTopCount =
|
||
learningTargetPosts.filter (
|
||
post => directSemanticAnswerForPost (question, post) == null).length
|
||
const knownCount = learnedSemanticStatsForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
question }).knownCount
|
||
|
||
return {
|
||
missingTopCount,
|
||
knownCount,
|
||
hasLearningValue: missingTopCount > 0 }
|
||
}
|
||
|
||
|
||
const learningTargetPostsForCandidates = ({
|
||
scoredPosts,
|
||
gameSeed,
|
||
}: {
|
||
scoredPosts: { post: Post; score: number }[]
|
||
gameSeed: string
|
||
}): Post[] => {
|
||
const topPosts = scoredPosts
|
||
.slice (0, Math.min (6, scoredPosts.length))
|
||
.map (item => item.post)
|
||
const topPostIds = new Set (topPosts.map (post => post.id))
|
||
const sampledPosts = scoredPosts
|
||
.filter (item => !(topPostIds.has (item.post.id)))
|
||
.map (item => ({
|
||
post: item.post,
|
||
weight: deterministicUnitFloat (`${ gameSeed }:learning-sample:${ item.post.id }`) }))
|
||
.sort ((a, b) => a.weight - b.weight)
|
||
.slice (0, Math.min (4, Math.max (0, scoredPosts.length - topPosts.length)))
|
||
.map (item => item.post)
|
||
|
||
return [...topPosts, ...sampledPosts]
|
||
}
|
||
|
||
|
||
const questionPurposeCountsFor = (
|
||
answers: GekanatorAnswerLog[]): {
|
||
effectiveUserSuggestedCount: number
|
||
learningUserSuggestedCount: number
|
||
normalQuestionCount: number
|
||
totalNormalPhaseQuestionCount: number
|
||
totalUserSuggestedCount: number } => {
|
||
let effectiveUserSuggestedCount = 0
|
||
let learningUserSuggestedCount = 0
|
||
let normalQuestionCount = 0
|
||
|
||
answers.forEach (answer => {
|
||
if (answer.questionMode !== 'normal')
|
||
return
|
||
|
||
switch (answer.questionPurpose)
|
||
{
|
||
case 'effective_user_suggested':
|
||
++effectiveUserSuggestedCount
|
||
return
|
||
case 'learning_user_suggested':
|
||
++learningUserSuggestedCount
|
||
return
|
||
default:
|
||
++normalQuestionCount
|
||
}
|
||
})
|
||
|
||
return {
|
||
effectiveUserSuggestedCount,
|
||
learningUserSuggestedCount,
|
||
normalQuestionCount,
|
||
totalNormalPhaseQuestionCount:
|
||
effectiveUserSuggestedCount + learningUserSuggestedCount + normalQuestionCount,
|
||
totalUserSuggestedCount:
|
||
effectiveUserSuggestedCount + learningUserSuggestedCount }
|
||
}
|
||
|
||
|
||
const learnedSemanticNarrowPenaltyForStats = (
|
||
candidateCount: number,
|
||
stats: LearnedSemanticCandidateStats): number => {
|
||
const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
|
||
return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
|
||
}
|
||
|
||
|
||
const learnedSemanticScoreDeltaForExpectedAnswer = (
|
||
userAnswer: GekanatorAnswerValue,
|
||
expectedAnswer: GekanatorAnswerValue | null): number => {
|
||
switch (userAnswer)
|
||
{
|
||
case 'yes':
|
||
if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
|
||
return 4
|
||
if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
|
||
return -4
|
||
return 0
|
||
case 'no':
|
||
if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
|
||
return -4
|
||
if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
|
||
return 4
|
||
return 0
|
||
case 'partial':
|
||
if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
|
||
return 2
|
||
if (expectedAnswer === 'no')
|
||
return -2
|
||
if (expectedAnswer === 'probably_no')
|
||
return -1
|
||
return 0
|
||
case 'probably_no':
|
||
if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
|
||
return -2
|
||
if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
|
||
return 1
|
||
return 0
|
||
case 'unknown':
|
||
return 0
|
||
}
|
||
}
|
||
|
||
|
||
const scoreDropDeltaForRecoveredPost = (
|
||
postId: number,
|
||
totalScore: number,
|
||
recoveredCandidatePosts: Map<number, RecoveredCandidateState>): number => {
|
||
const recoveredCandidate = recoveredCandidatePosts.get (postId)
|
||
if (recoveredCandidate == null)
|
||
return totalScore
|
||
|
||
return totalScore - recoveredCandidate.scoreAtRecovery
|
||
}
|
||
|
||
|
||
const activeCandidateScoreDropEnabled = (scores: Map<number, number>): boolean => {
|
||
if (scores.size === 0)
|
||
return false
|
||
|
||
const maxScore = Math.max (...scores.values ())
|
||
return maxScore >= scoreDropActivationThreshold
|
||
}
|
||
|
||
|
||
const postPassesScoreDrop = (
|
||
{ postId,
|
||
scores,
|
||
recoveredCandidatePosts }: {
|
||
postId: number
|
||
scores: Map<number, number>
|
||
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): boolean => {
|
||
if (!(activeCandidateScoreDropEnabled (scores)))
|
||
return true
|
||
|
||
const totalScore = scores.get (postId) ?? 0
|
||
return scoreDropDeltaForRecoveredPost (
|
||
postId,
|
||
totalScore,
|
||
recoveredCandidatePosts) >= 0
|
||
}
|
||
|
||
|
||
// `post_similarities` is the score-propagation graph, not the question kind.
|
||
const questionUsesPostSimilarityPropagationGraphForScoring = (
|
||
question: GekanatorQuestion): boolean =>
|
||
(question.kind === 'post_similarity'
|
||
&& !(isUserSuggestedLearnedSemanticQuestion (question)))
|
||
|| (question.kind === 'tag'
|
||
&& question.condition.type === 'tag'
|
||
&& !(question.condition.key.startsWith ('nico:')))
|
||
|
||
|
||
const questionSupportsAnswerBasedHardFiltering = (
|
||
question: GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
|
||
&& !(isUserSuggestedLearnedSemanticQuestion (question))
|
||
|
||
|
||
const isLearnableQuestionForUserAnswer = (question: GekanatorQuestion): boolean =>
|
||
question.kind === 'post_similarity'
|
||
|| (question.kind === 'tag'
|
||
&& question.condition.type === 'tag'
|
||
&& isLearnableTagKey (question.condition.key))
|
||
|
||
|
||
const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
|
||
question.kind === 'tag'
|
||
&& question.condition.type === 'tag'
|
||
&& Boolean (question.exampleAnswers)
|
||
&& Object.keys (question.exampleAnswers ?? { }).length > 0
|
||
|
||
|
||
const searchedQuestionsFor = (
|
||
questions: GekanatorQuestion[],
|
||
search: string): GekanatorQuestion[] => {
|
||
const needle = search.trim ()
|
||
if (!(needle))
|
||
return []
|
||
|
||
const normalizedNeedle = needle.toLowerCase ()
|
||
const prefixMatches = questions.filter (question =>
|
||
isLearnableQuestionForUserAnswer (question)
|
||
&& question.text.toLowerCase ().startsWith (normalizedNeedle))
|
||
const partialMatches = questions.filter (question =>
|
||
isLearnableQuestionForUserAnswer (question)
|
||
&& !(question.text.toLowerCase ().startsWith (normalizedNeedle))
|
||
&& question.text.toLowerCase ().includes (normalizedNeedle))
|
||
|
||
return [...prefixMatches, ...partialMatches].slice (0, 20)
|
||
}
|
||
|
||
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> => {
|
||
if (usesLearnedTagExamples (question))
|
||
{
|
||
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 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 positiveMatchingPostIdsForQuestion = (
|
||
resolver: QuestionMatchResolver): Set<number> => {
|
||
if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
|
||
{
|
||
const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
|
||
if (cached)
|
||
return cached
|
||
|
||
const computed = new Set (
|
||
resolver.posts
|
||
.filter (post =>
|
||
learnedSemanticSideForPost (resolver.question, post) === 'positive')
|
||
.map (post => post.id))
|
||
resolver.dynamicMatchIndex?.set (resolver.question.id, computed)
|
||
return computed
|
||
}
|
||
|
||
return matchingPostIdsForQuestion (resolver)
|
||
}
|
||
|
||
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 = positiveMatchingPostIdsForQuestion ({
|
||
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 = positiveMatchingPostIdsForQuestion ({
|
||
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 => {
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
{
|
||
const postById = new Map (posts.map (post => [post.id, post]))
|
||
|
||
return candidateIds.map (postId => {
|
||
const post = postById.get (postId) ?? null
|
||
const side = learnedSemanticSideForPost (question, post)
|
||
if (side === 'positive')
|
||
return '1'
|
||
if (side === 'negative')
|
||
return '0'
|
||
|
||
return 'u'
|
||
}).join ('')
|
||
}
|
||
|
||
const matched = positiveMatchingPostIdsForQuestion ({
|
||
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 (!(questionSupportsAnswerBasedHardFiltering (question)))
|
||
return candidateIds
|
||
|
||
if (answer === 'unknown'
|
||
|| answer === 'partial'
|
||
|| answer === 'probably_no')
|
||
return candidateIds
|
||
|
||
if (answer === 'yes')
|
||
{
|
||
const matched = positiveMatchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
return candidateIds.filter (postId => matched.has (postId))
|
||
}
|
||
|
||
if (answer === 'no')
|
||
{
|
||
const matched = positiveMatchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
return candidateIds.filter (postId => !(matched.has (postId)))
|
||
}
|
||
|
||
return candidateIds
|
||
}
|
||
|
||
|
||
const applyQuestionAnswerDeltaToScores = ({
|
||
posts,
|
||
question,
|
||
answer,
|
||
weight,
|
||
nextScores,
|
||
materialIndex,
|
||
matchIndex,
|
||
dynamicMatchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
question: GekanatorQuestion
|
||
answer: GekanatorAnswerValue
|
||
weight: number
|
||
nextScores: Map<number, number>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
dynamicMatchIndex: GekanatorMatchIndex
|
||
}): void => {
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
{
|
||
posts.forEach (post => {
|
||
const delta = learnedSemanticScoreDeltaForExpectedAnswer (
|
||
answer,
|
||
expectedAnswerForQuestion (question, post))
|
||
if (delta === 0)
|
||
return
|
||
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0) + delta * weight)
|
||
})
|
||
return
|
||
}
|
||
|
||
const baseDelta = baseDeltaForAnswer (answer) * weight
|
||
if (baseDelta === 0)
|
||
return
|
||
|
||
if (!(questionUsesPostSimilarityPropagationGraphForScoring (question)))
|
||
{
|
||
posts.forEach (post => {
|
||
const delta = learnedSemanticScoreDeltaForExpectedAnswer (
|
||
answer,
|
||
expectedAnswerForQuestion (question, post))
|
||
if (delta === 0)
|
||
return
|
||
|
||
nextScores.set (
|
||
post.id,
|
||
(nextScores.get (post.id) ?? 0) + delta * weight)
|
||
})
|
||
return
|
||
}
|
||
|
||
const matched = positiveMatchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
|
||
matched.forEach (postId => {
|
||
nextScores.set (
|
||
postId,
|
||
(nextScores.get (postId) ?? 0) + baseDelta)
|
||
})
|
||
|
||
// `post_similarities` is the propagation graph. Directly matched posts
|
||
// get only the base delta; only non-direct neighbors get `base delta * cos`.
|
||
// When several matched posts point at the same neighbor, keep the largest
|
||
// absolute propagated contribution instead of summing all of them.
|
||
const propagatedDeltaByPostId = new Map<number, number> ()
|
||
matched.forEach (postId => {
|
||
const post = materialIndex.postById.get (postId)
|
||
post?.postSimilarityEdges?.forEach (edge => {
|
||
if (!Number.isFinite (edge.cos) || edge.cos <= 0)
|
||
return
|
||
if (matched.has (edge.targetPostId))
|
||
return
|
||
|
||
const propagatedDelta = baseDelta * edge.cos
|
||
const current = propagatedDeltaByPostId.get (edge.targetPostId)
|
||
if (current == null || Math.abs (propagatedDelta) > Math.abs (current))
|
||
propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta)
|
||
})
|
||
})
|
||
|
||
propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => {
|
||
nextScores.set (
|
||
postId,
|
||
(nextScores.get (postId) ?? 0) + propagatedDelta)
|
||
})
|
||
}
|
||
|
||
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,
|
||
acceptedQuestions,
|
||
mode = 'split',
|
||
confirmationPostId = null }: { candidateIds: number[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
acceptedQuestions: GekanatorQuestion[]
|
||
mode?: QuestionBuildMode
|
||
confirmationPostId?: number | null }): GekanatorQuestion[] => {
|
||
const total = candidateIds.length
|
||
const confirmationPost = (() => {
|
||
if (confirmationPostId == null)
|
||
return null
|
||
|
||
return materialIndex.postById.get (confirmationPostId) ?? null
|
||
}) ()
|
||
|
||
if (mode === 'split' && total === 0)
|
||
return acceptedQuestions
|
||
if (mode === 'confirmation' && confirmationPost == null)
|
||
return acceptedQuestions
|
||
|
||
const tagCounts = new Map<string, number> ()
|
||
const hostCounts = new Map<string, number> ()
|
||
const yearCounts = 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)
|
||
yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1)
|
||
const monthDay = materialIndex.originalMonthDayByPostId.get (postId)
|
||
if (monthDay)
|
||
monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1)
|
||
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 = total >= 120 ? 128 : 96
|
||
const titleTermCap = 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[] = []
|
||
const addQuestion = (question: GekanatorQuestion | null) => {
|
||
if (question)
|
||
questions.push (question)
|
||
}
|
||
const buildDateQuestion = (
|
||
condition: Extract<
|
||
GekanatorQuestionCondition,
|
||
{ type: 'original-year' | 'original-month' | 'original-month-day' }
|
||
>): GekanatorQuestion => {
|
||
const priorityWeight = (() => {
|
||
if (condition.type === 'original-year')
|
||
return 1.04
|
||
if (condition.type === 'original-month-day')
|
||
return 1.01
|
||
|
||
return .92
|
||
}) ()
|
||
|
||
return buildIndexedQuestion ({
|
||
condition,
|
||
text: originalDateQuestionTextFor (condition),
|
||
kind: 'original_date',
|
||
priorityWeight,
|
||
materialIndex })
|
||
}
|
||
const specialMonthDays = rankedEntriesForCounts ({
|
||
counts: monthDayCounts,
|
||
total,
|
||
cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)
|
||
|
||
if (mode === 'split')
|
||
{
|
||
rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap })
|
||
.forEach (([key]) => {
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'tag', key },
|
||
text: indexedQuestionTextForTag (key),
|
||
kind: 'tag',
|
||
priorityWeight: 1.08,
|
||
materialIndex }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap })
|
||
.forEach (([host]) => {
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'source', host },
|
||
text: `${ host } の投稿を思い浮かべている?`,
|
||
kind: 'source',
|
||
priorityWeight: 1.02,
|
||
materialIndex }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap })
|
||
.forEach (([year]) => {
|
||
addQuestion (buildDateQuestion ({
|
||
type: 'original-year',
|
||
year }))
|
||
})
|
||
|
||
specialMonthDays.forEach (([monthDay]) => {
|
||
addQuestion (buildDateQuestion ({
|
||
type: 'original-month-day',
|
||
monthDay: String (monthDay) }))
|
||
})
|
||
|
||
rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap })
|
||
.filter (([term]) => String (term).length <= 24)
|
||
.forEach (([term]) => {
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'title-contains', text: String (term) },
|
||
text: `タイトルに「${ term }」が含まれる?`,
|
||
kind: 'title',
|
||
priorityWeight: .99,
|
||
materialIndex }))
|
||
})
|
||
|
||
if (titleLengthMedian > 0)
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: {
|
||
type: 'title-length-at-least',
|
||
length: titleLengthMedian },
|
||
text: `タイトルは ${ titleLengthMedian } 文字以上?`,
|
||
kind: 'title',
|
||
priorityWeight: .72,
|
||
materialIndex }))
|
||
|
||
if (asciiCount > 0 && asciiCount < total)
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'title-has-ascii' },
|
||
text: 'タイトルに英数字が混じっている?',
|
||
kind: 'title',
|
||
priorityWeight: .68,
|
||
materialIndex }))
|
||
}
|
||
else if (confirmationPost)
|
||
{
|
||
const targetPostId = confirmationPost.id
|
||
const host = materialIndex.hostByPostId.get (targetPostId)
|
||
const year = materialIndex.originalYearByPostId.get (targetPostId)
|
||
const monthDay = materialIndex.originalMonthDayByPostId.get (targetPostId)
|
||
const titleTerms = materialIndex.titleTermsByPostId.get (targetPostId) ?? []
|
||
const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0
|
||
const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? []
|
||
|
||
if (host)
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'source', host },
|
||
text: `${ host } の投稿を思い浮かべている?`,
|
||
kind: 'source',
|
||
priorityWeight: 1.02,
|
||
materialIndex }))
|
||
if (year != null)
|
||
addQuestion (buildDateQuestion ({
|
||
type: 'original-year',
|
||
year }))
|
||
if (monthDay && specialOriginalMonthDayLabelFor (monthDay))
|
||
addQuestion (buildDateQuestion ({
|
||
type: 'original-month-day',
|
||
monthDay }))
|
||
|
||
tagKeys
|
||
.slice (0, 20)
|
||
.forEach (key => {
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'tag', key },
|
||
text: indexedQuestionTextForTag (key),
|
||
kind: 'tag',
|
||
priorityWeight: 1.08,
|
||
materialIndex }))
|
||
})
|
||
|
||
titleTerms
|
||
.filter (term => term.length <= 24)
|
||
.slice (0, 8)
|
||
.forEach (term => {
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'title-contains', text: term },
|
||
text: `タイトルに「${ term }」が含まれる?`,
|
||
kind: 'title',
|
||
priorityWeight: .99,
|
||
materialIndex }))
|
||
})
|
||
|
||
if (titleLength > 0)
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: {
|
||
type: 'title-length-at-least',
|
||
length: titleLength },
|
||
text: `タイトルは ${ titleLength } 文字以上?`,
|
||
kind: 'title',
|
||
priorityWeight: .72,
|
||
materialIndex }))
|
||
|
||
if (materialIndex.titleAsciiPostIds.has (targetPostId))
|
||
addQuestion (buildIndexedQuestion ({
|
||
condition: { type: 'title-has-ascii' },
|
||
text: 'タイトルに英数字が混じっている?',
|
||
kind: 'title',
|
||
priorityWeight: .68,
|
||
materialIndex }))
|
||
}
|
||
|
||
return mergeQuestions ([...questions, ...acceptedQuestions])
|
||
}
|
||
|
||
const candidatePostsForState = ({
|
||
posts,
|
||
questionById,
|
||
materialIndex,
|
||
matchIndex,
|
||
answers,
|
||
softenedQuestionIds,
|
||
rejectedPostIds,
|
||
recoveredCandidatePosts,
|
||
scores,
|
||
}: {
|
||
posts: Post[]
|
||
questionById: Map<string, GekanatorQuestion>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
answers: GekanatorAnswerLog[]
|
||
softenedQuestionIds: Set<string>
|
||
rejectedPostIds: Set<number>
|
||
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
|
||
scores: Map<number, number>
|
||
}): Post[] => {
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean =>
|
||
answer === 'yes' || answer === 'no'
|
||
|
||
return posts.filter (post => {
|
||
if (rejectedPostIds.has (post.id))
|
||
return false
|
||
|
||
const recoveredCandidate = recoveredCandidatePosts.get (post.id)
|
||
|
||
const survivesHardFiltering = answers.every ((answer, index) => {
|
||
if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
|
||
return true
|
||
if (softenedQuestionIds.has (answer.questionId))
|
||
return true
|
||
if (!(answerAllowsHardFilter (answer.answer)))
|
||
return true
|
||
|
||
const question = questionById.get (answer.questionId)
|
||
const condition = answer.questionCondition ?? question?.condition
|
||
if (!(condition))
|
||
return true
|
||
|
||
const matched = (() => {
|
||
if (question)
|
||
return positiveMatchingPostIdsForQuestion ({
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
|
||
return matchingPostIdsForCondition ({
|
||
condition,
|
||
materialIndex })
|
||
}) ()
|
||
const useExpectedAnswer =
|
||
question != null
|
||
&& usesLearnedTagExamples (question)
|
||
if (question && !(questionSupportsAnswerBasedHardFiltering (question)))
|
||
return true
|
||
|
||
if (matched != null)
|
||
{
|
||
if (useExpectedAnswer)
|
||
{
|
||
const expected = expectedAnswerForQuestion (question, post)
|
||
return expected == null
|
||
|| expected === 'unknown'
|
||
|| expected === answer.answer
|
||
}
|
||
|
||
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
|
||
})
|
||
if (!(survivesHardFiltering))
|
||
return false
|
||
|
||
return postPassesScoreDrop ({
|
||
postId: post.id,
|
||
scores,
|
||
recoveredCandidatePosts })
|
||
})
|
||
}
|
||
|
||
const hasDiscriminatingHardSplitForQuestion = ({
|
||
candidateIds,
|
||
question,
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
candidateIds: number[]
|
||
question: GekanatorQuestion | null
|
||
posts: Post[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): boolean => {
|
||
if (!(question))
|
||
return false
|
||
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
return learnedSemanticQuestionIsEffectiveForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
question })
|
||
|
||
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 = scoreWeightForAnswer (answer, softenedQuestionIds)
|
||
applyQuestionAnswerDeltaToScores ({
|
||
posts,
|
||
question,
|
||
answer: answer.answer,
|
||
weight,
|
||
nextScores,
|
||
materialIndex,
|
||
matchIndex,
|
||
dynamicMatchIndex })
|
||
})
|
||
|
||
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 != null)
|
||
if (nextPosts.length === 0)
|
||
return {
|
||
answer,
|
||
top: null,
|
||
candidateCount: 0,
|
||
effectiveCandidates: 0,
|
||
entropy: 0 }
|
||
|
||
const nextScores = new Map (scores)
|
||
applyQuestionAnswerDeltaToScores ({
|
||
posts,
|
||
question,
|
||
answer,
|
||
weight: 1,
|
||
nextScores,
|
||
materialIndex,
|
||
matchIndex,
|
||
dynamicMatchIndex })
|
||
|
||
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 != null
|
||
&& 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':
|
||
if (
|
||
sameConditionValue (question.condition, previous)
|
||
|| isMonthCrossMatch (question.condition, previous))
|
||
return sum + 40
|
||
|
||
return sum
|
||
case 'probably_no':
|
||
if (
|
||
sameConditionValue (question.condition, previous)
|
||
|| isMonthCrossMatch (question.condition, previous))
|
||
return sum + 20
|
||
|
||
return sum
|
||
default:
|
||
return sum
|
||
}
|
||
}, 0)
|
||
}
|
||
|
||
|
||
const chooseQuestion = (
|
||
{ posts,
|
||
questions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
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>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
|
||
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
|
||
}
|
||
|
||
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 learningTargetPosts = learningTargetPostsForCandidates ({
|
||
scoredPosts,
|
||
gameSeed })
|
||
|
||
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
|
||
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
{
|
||
const stats = learnedSemanticStatsForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
question })
|
||
const effective =
|
||
learnedSemanticQuestionIsEffectiveForCandidateIds ({
|
||
candidateIds,
|
||
posts,
|
||
question })
|
||
const learningValue = learnedSemanticLearningValueForTopPosts ({
|
||
question,
|
||
learningTargetPosts,
|
||
candidateIds,
|
||
posts })
|
||
if (!(effective) && !(learningValue.hasLearningValue))
|
||
return null
|
||
|
||
const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
|
||
const humanOffset = humanPriorityOffsetFor (question)
|
||
const sourceBonus = sourcePriorityOffset (question)
|
||
const priorityBonus = priorityWeightOffset (question)
|
||
const repeatPenalty = (() => {
|
||
if (answers.length === 0)
|
||
return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
|
||
|
||
return 0
|
||
}) ()
|
||
const categoryPenalty = questionCategoryPenalty (
|
||
question,
|
||
answers.length,
|
||
repeatPenalty)
|
||
if (!(effective))
|
||
{
|
||
return {
|
||
question,
|
||
score: (
|
||
-(learningValue.missingTopCount * 20)
|
||
+ learningValue.knownCount * .5
|
||
+ contradictionPenalty
|
||
+ humanOffset
|
||
+ sourceBonus
|
||
+ priorityBonus
|
||
+ categoryPenalty),
|
||
narrow: false,
|
||
effectiveUserSuggested: false,
|
||
learningUserSuggested: true }
|
||
}
|
||
|
||
const positiveWeight = weightedCandidates.reduce ((sum, item) =>
|
||
sum + (
|
||
stats.positiveIds.has (item.post.id)
|
||
? item.weight
|
||
: 0),
|
||
0)
|
||
const negativeWeight = weightedCandidates.reduce ((sum, item) =>
|
||
sum + (
|
||
stats.negativeIds.has (item.post.id)
|
||
? item.weight
|
||
: 0),
|
||
0)
|
||
const unknownWeight = Math.max (0, 1 - positiveWeight - negativeWeight)
|
||
if (positiveWeight <= 0 || negativeWeight <= 0)
|
||
return null
|
||
|
||
const positivePosteriorWeights = weightedCandidates
|
||
.filter (item => stats.positiveIds.has (item.post.id))
|
||
.map (item => item.weight / positiveWeight)
|
||
const negativePosteriorWeights = weightedCandidates
|
||
.filter (item => stats.negativeIds.has (item.post.id))
|
||
.map (item => item.weight / negativeWeight)
|
||
const unknownPosteriorWeights =
|
||
unknownWeight > 0
|
||
? weightedCandidates
|
||
.filter (item => stats.unknownIds.has (item.post.id))
|
||
.map (item => item.weight / unknownWeight)
|
||
: []
|
||
const infoGain =
|
||
weightedEntropy
|
||
- (
|
||
positiveWeight * distributionEntropy (positivePosteriorWeights)
|
||
+ negativeWeight * distributionEntropy (negativePosteriorWeights)
|
||
+ unknownWeight * distributionEntropy (unknownPosteriorWeights))
|
||
if (infoGain < (candidates.length >= 10 ? .02 : .008))
|
||
return null
|
||
|
||
const weightedSplitScore = Math.abs (.5 - positiveWeight)
|
||
const unweightedSplitScore =
|
||
Math.abs (candidates.length / 2 - stats.positiveCount) / candidates.length
|
||
const narrowPenalty =
|
||
learnedSemanticNarrowPenaltyForStats (candidates.length, stats)
|
||
const infoGainBonus = -Math.min (1.2, infoGain) * 4
|
||
|
||
return {
|
||
question,
|
||
score: weightedSplitScore * 100
|
||
+ unweightedSplitScore * 8
|
||
+ narrowPenalty
|
||
+ contradictionPenalty
|
||
+ humanOffset
|
||
+ sourceBonus
|
||
+ priorityBonus
|
||
+ categoryPenalty
|
||
+ infoGainBonus,
|
||
narrow: narrowPenalty > 0,
|
||
effectiveUserSuggested: true,
|
||
learningUserSuggested: false }
|
||
}
|
||
|
||
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 = positiveMatchingPostIdsForQuestion ({
|
||
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 humanOffset = humanPriorityOffsetFor (question)
|
||
const sourceBonus = sourcePriorityOffset (question)
|
||
const priorityBonus = priorityWeightOffset (question)
|
||
const repeatPenalty = (() => {
|
||
if (answers.length === 0)
|
||
return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5
|
||
|
||
return 0
|
||
}) ()
|
||
const categoryPenalty = questionCategoryPenalty (
|
||
question,
|
||
answers.length,
|
||
repeatPenalty)
|
||
const priorSplitScore = (() => {
|
||
if (priorWeightTotal <= 0)
|
||
return null
|
||
|
||
return Math.abs (
|
||
.5 - (
|
||
priorEntries.reduce (
|
||
(sum, [postId, weight]) => {
|
||
return sum + (matched.has (postId) ? weight : 0)
|
||
},
|
||
0) / priorWeightTotal))
|
||
}) ()
|
||
const priorBonus = (() => {
|
||
if (priorSplitScore == null)
|
||
return 0
|
||
|
||
return Math.max (0, .22 - priorSplitScore) * -18
|
||
}) ()
|
||
const infoGainBonus = -Math.min (1.2, infoGain) * 4
|
||
|
||
return { question,
|
||
score: weightedSplitScore * 100
|
||
+ unweightedSplitScore * 8
|
||
+ tagPenalty
|
||
+ narrowPenalty
|
||
+ contradictionPenalty
|
||
+ humanOffset
|
||
+ sourceBonus
|
||
+ priorityBonus
|
||
+ categoryPenalty
|
||
+ priorBonus
|
||
+ infoGainBonus,
|
||
narrow: narrowPenalty > 0,
|
||
effectiveUserSuggested: false,
|
||
learningUserSuggested: false }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
score: number
|
||
narrow: boolean
|
||
effectiveUserSuggested: boolean
|
||
learningUserSuggested: 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 generalRankedPool =
|
||
ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked
|
||
const effectiveUserSuggestedPool =
|
||
ranked
|
||
.filter (item => item.effectiveUserSuggested)
|
||
.slice (0, 16)
|
||
const learningUserSuggestedPool =
|
||
ranked
|
||
.filter (item => item.learningUserSuggested)
|
||
.slice (0, 16)
|
||
const normalPool =
|
||
generalRankedPool
|
||
.filter (item => !(item.effectiveUserSuggested) && !(item.learningUserSuggested))
|
||
.slice (0, 16)
|
||
const purposeCounts = questionPurposeCountsFor (answers)
|
||
const totalNormalPhaseQuestionCount = purposeCounts.totalNormalPhaseQuestionCount
|
||
const effectiveRatio =
|
||
totalNormalPhaseQuestionCount > 0
|
||
? purposeCounts.effectiveUserSuggestedCount / totalNormalPhaseQuestionCount
|
||
: 0
|
||
const learningRatio =
|
||
totalNormalPhaseQuestionCount > 0
|
||
? purposeCounts.learningUserSuggestedCount / totalNormalPhaseQuestionCount
|
||
: 0
|
||
const totalUserSuggestedRatio =
|
||
totalNormalPhaseQuestionCount > 0
|
||
? purposeCounts.totalUserSuggestedCount / totalNormalPhaseQuestionCount
|
||
: 0
|
||
let selectedPool = normalPool
|
||
let selectedPurpose: GekanatorQuestionPurpose = 'normal'
|
||
|
||
if (
|
||
effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
|
||
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
|
||
&& effectiveUserSuggestedPool.length > 0)
|
||
{
|
||
selectedPool = effectiveUserSuggestedPool
|
||
selectedPurpose = 'effective_user_suggested'
|
||
}
|
||
else if (
|
||
learningRatio < targetLearningUserSuggestedQuestionRatio
|
||
&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
|
||
&& learningUserSuggestedPool.length > 0)
|
||
{
|
||
selectedPool = learningUserSuggestedPool
|
||
selectedPurpose = 'learning_user_suggested'
|
||
}
|
||
else if (normalPool.length > 0)
|
||
{
|
||
selectedPool = normalPool
|
||
selectedPurpose = 'normal'
|
||
}
|
||
else if (effectiveUserSuggestedPool.length > 0)
|
||
{
|
||
selectedPool = effectiveUserSuggestedPool
|
||
selectedPurpose = 'effective_user_suggested'
|
||
}
|
||
else if (learningUserSuggestedPool.length > 0)
|
||
{
|
||
selectedPool = learningUserSuggestedPool
|
||
selectedPurpose = 'learning_user_suggested'
|
||
}
|
||
|
||
if (selectedPool.length === 0)
|
||
return null
|
||
|
||
const bestScore = selectedPool[0]?.score ?? 0
|
||
const weightedPool = selectedPool.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 {
|
||
question: item.question,
|
||
questionPurpose: selectedPurpose,
|
||
effectiveQuestion: selectedPurpose === 'effective_user_suggested',
|
||
learningQuestion: selectedPurpose === 'learning_user_suggested' }
|
||
}
|
||
|
||
const selectedQuestion = weightedPool[weightedPool.length - 1]?.question
|
||
if (selectedQuestion == null)
|
||
return null
|
||
|
||
return {
|
||
question: selectedQuestion,
|
||
questionPurpose: selectedPurpose,
|
||
effectiveQuestion: selectedPurpose === 'effective_user_suggested',
|
||
learningQuestion: selectedPurpose === 'learning_user_suggested' }
|
||
}
|
||
|
||
|
||
const winningRunPriorityFor = (
|
||
expected: GekanatorAnswerValue): number | 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,
|
||
acceptedQuestions,
|
||
materialIndex,
|
||
matchIndex,
|
||
}: {
|
||
posts: Post[]
|
||
targetPost: Post
|
||
answers: GekanatorAnswerLog[]
|
||
askedIds: Set<string>
|
||
acceptedQuestions: GekanatorQuestion[]
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
}): GekanatorQuestion | null => {
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const ranked = buildQuestionsForCandidateIds ({
|
||
candidateIds: posts.map (post => post.id),
|
||
materialIndex,
|
||
acceptedQuestions,
|
||
mode: 'confirmation',
|
||
confirmationPostId: targetPost.id})
|
||
.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 (expected)
|
||
if (priority == null)
|
||
return null
|
||
|
||
let matchingCount = 0
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
{
|
||
const stats = learnedSemanticStatsForCandidateIds ({
|
||
candidateIds: posts.map (post => post.id),
|
||
posts,
|
||
question })
|
||
matchingCount =
|
||
expected === 'yes' || expected === 'partial'
|
||
? stats.positiveCount
|
||
: stats.negativeCount
|
||
}
|
||
else
|
||
{
|
||
const yesCount = matchingPostCountInIds ({
|
||
candidateIds: posts.map (post => post.id),
|
||
posts,
|
||
materialIndex,
|
||
matchIndex,
|
||
question,
|
||
dynamicMatchIndex })
|
||
matchingCount =
|
||
expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount
|
||
}
|
||
|
||
return {
|
||
question,
|
||
priority,
|
||
humanOffset: humanPriorityOffsetFor (question),
|
||
matchingCount }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
priority: number
|
||
humanOffset: number
|
||
matchingCount: number } => item != null)
|
||
.sort ((a, b) => {
|
||
if (a.priority !== b.priority)
|
||
return a.priority - b.priority
|
||
|
||
if (a.humanOffset !== b.humanOffset)
|
||
return a.humanOffset - b.humanOffset
|
||
|
||
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 candidateIds = posts.map (post => post.id)
|
||
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 => buildQuestionsForCandidateIds ({
|
||
candidateIds,
|
||
materialIndex,
|
||
acceptedQuestions: [],
|
||
mode: 'confirmation',
|
||
confirmationPostId: post.id})))
|
||
.slice (0, 32)
|
||
const dynamicMatchIndex = new Map<string, Set<number>> ()
|
||
const ranked = mergeQuestions ([
|
||
...questions,
|
||
...fallbackQuestions])
|
||
.filter (question =>
|
||
question.source !== 'user_suggested'
|
||
&&
|
||
!(askedIds.has (question.id))
|
||
&& !(isQuestionHardFilteredAfterAnswers (question, answers)))
|
||
.map (question => {
|
||
if (isUserSuggestedLearnedSemanticQuestion (question))
|
||
{
|
||
if (!(learnedSemanticQuestionIsEffectiveForCandidateIds ({
|
||
candidateIds,
|
||
posts: allPosts,
|
||
question })))
|
||
return null
|
||
|
||
const stats = learnedSemanticStatsForCandidateIds ({
|
||
candidateIds,
|
||
posts: allPosts,
|
||
question })
|
||
|
||
return {
|
||
question,
|
||
knownCount: stats.knownCount,
|
||
balance: Math.abs (stats.positiveCount - stats.negativeCount),
|
||
humanOffset: humanPriorityOffsetFor (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),
|
||
humanOffset: humanPriorityOffsetFor (question) }
|
||
})
|
||
.filter ((item): item is {
|
||
question: GekanatorQuestion
|
||
knownCount: number
|
||
balance: number
|
||
humanOffset: number } => item != null)
|
||
.sort ((a, b) => {
|
||
if (a.humanOffset !== b.humanOffset)
|
||
return a.humanOffset - b.humanOffset
|
||
|
||
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 => {
|
||
if (winningRunStartAnswerCount == null)
|
||
return 0
|
||
|
||
return answers
|
||
.slice (winningRunStartAnswerCount)
|
||
.filter (answer => answer.questionMode === 'winning_run')
|
||
.length
|
||
}
|
||
|
||
|
||
const nextQuestionPlanFor = (
|
||
{ posts,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
acceptedQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
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>
|
||
materialIndex: GekanatorQuestionMaterialIndex
|
||
matchIndex: GekanatorMatchIndex
|
||
lastGuessQuestionCount: number
|
||
winningRunTargetId: number | null
|
||
winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
|
||
guess: Post | null
|
||
guessReason: GuessReason | null
|
||
questionMode: QuestionMode
|
||
questionPurpose?: GekanatorQuestionPurpose
|
||
effectiveQuestion?: boolean
|
||
learningQuestion?: boolean
|
||
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,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }
|
||
}
|
||
|
||
if (checkpointGuess)
|
||
{
|
||
return {
|
||
question: null,
|
||
guess: bestPost (guessablePosts, scores),
|
||
guessReason: 'question_count_checkpoint',
|
||
questionMode: null,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }
|
||
}
|
||
|
||
const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null
|
||
const nextWinningRunStartAnswerCount = (() => {
|
||
if (nextWinningRunTargetId == null)
|
||
return null
|
||
if (
|
||
isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
|
||
&& winningRunTargetId === nextWinningRunTargetId
|
||
&& winningRunStartAnswerCount != null)
|
||
return winningRunStartAnswerCount
|
||
|
||
return answers.length
|
||
}) ()
|
||
const nextWinningRunTargetPost = (() => {
|
||
if (nextWinningRunTargetId == null)
|
||
return null
|
||
|
||
return posts.find (post => post.id === nextWinningRunTargetId) ?? null
|
||
}) ()
|
||
const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] =>
|
||
buildQuestionsForCandidateIds ({
|
||
candidateIds: scopePosts.map (post => post.id),
|
||
materialIndex,
|
||
acceptedQuestions,
|
||
mode: 'split' })
|
||
|
||
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,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount == null)
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
|
||
const winningRunQuestion = chooseWinningRunQuestion ({
|
||
posts,
|
||
targetPost: nextWinningRunTargetPost,
|
||
answers,
|
||
askedIds,
|
||
acceptedQuestions,
|
||
materialIndex,
|
||
matchIndex })
|
||
if (winningRunQuestion)
|
||
return {
|
||
question: winningRunQuestion,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: 'winning_run',
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
}
|
||
|
||
const evaluationPosts =
|
||
eligiblePosts
|
||
|
||
const evaluationQuestions = buildQuestionsForPosts (evaluationPosts)
|
||
const normalQuestionSelection = chooseQuestion ({
|
||
posts: evaluationPosts,
|
||
questions: evaluationQuestions,
|
||
scores,
|
||
answers,
|
||
askedIds,
|
||
gameSeed,
|
||
recentFirstQuestionPenaltyById,
|
||
userPriorWeights,
|
||
materialIndex,
|
||
matchIndex })
|
||
|
||
const fallbackQuestion = normalQuestionSelection?.question ?? chooseFallbackQuestion ({
|
||
posts: evaluationPosts,
|
||
allPosts: posts,
|
||
questions: evaluationQuestions,
|
||
answers,
|
||
askedIds,
|
||
scores,
|
||
materialIndex,
|
||
matchIndex })
|
||
|
||
if (fallbackQuestion)
|
||
{
|
||
return {
|
||
question: fallbackQuestion,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: 'normal',
|
||
questionPurpose: normalQuestionSelection?.question?.id === fallbackQuestion.id
|
||
? normalQuestionSelection.questionPurpose
|
||
: 'normal',
|
||
effectiveQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id
|
||
? normalQuestionSelection.effectiveQuestion
|
||
: false,
|
||
learningQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id
|
||
? normalQuestionSelection.learningQuestion
|
||
: false,
|
||
winningRunTargetId: nextWinningRunTargetId,
|
||
winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
|
||
}
|
||
|
||
return {
|
||
question: null,
|
||
guess: null,
|
||
guessReason: null,
|
||
questionMode: null,
|
||
questionPurpose: undefined,
|
||
effectiveQuestion: false,
|
||
learningQuestion: false,
|
||
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 = (() => {
|
||
if (guessThumbnail)
|
||
return 'guess'
|
||
if (isWinningRunBackdrop)
|
||
return 'winning_run'
|
||
|
||
return '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 }
|
||
|
||
if (motionMode === 'calm')
|
||
return { columns: 7, rows: 7, opacity: .14 }
|
||
|
||
return { 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 = (() => {
|
||
if (backdropMode === 'winning_run')
|
||
return motionMode === 'calm' ? 28 : 20
|
||
|
||
return 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 guessAnimationControlsRef = useRef<ReturnType<typeof animate>[]> ([])
|
||
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 (() => {
|
||
guessAnimationControlsRef.current.forEach (control => control.stop ())
|
||
guessAnimationControlsRef.current = []
|
||
|
||
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 })]
|
||
guessAnimationControlsRef.current = controls
|
||
|
||
return () => {
|
||
controls.forEach (control => control.stop ())
|
||
guessAnimationControlsRef.current = []
|
||
}
|
||
}, [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)
|
||
{
|
||
guessAnimationControlsRef.current.forEach (control => control.stop ())
|
||
guessAnimationControlsRef.current = []
|
||
x.set (0)
|
||
y.set (0)
|
||
return
|
||
}
|
||
|
||
if (isGuessPresentation)
|
||
{
|
||
guessAnimationControlsRef.current.forEach (control => control.stop ())
|
||
guessAnimationControlsRef.current = []
|
||
x.set (0)
|
||
y.set (0)
|
||
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"/>)
|
||
|
||
const backdropTransition: Transition = (() => {
|
||
if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess')
|
||
return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] }
|
||
|
||
return { duration: .2 }
|
||
}) ()
|
||
|
||
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={backdropTransition}>
|
||
{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
|
||
|
||
const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail
|
||
const showStaticTile =
|
||
displayedBackdropMode !== 'normal' || !(isFlippingTiles)
|
||
|
||
return (
|
||
<motion.div
|
||
key={`${ duplicate }:${ index }`}
|
||
className="relative overflow-hidden"
|
||
layout={displayedBackdropMode !== 'normal'}
|
||
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
|
||
style={{ perspective: 1600 }}>
|
||
{showStaticTile && (
|
||
<img
|
||
src={imageSource}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
style={{ opacity: renderedSettings.opacity }}/>)
|
||
}
|
||
{!(showStaticTile) && (
|
||
<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 [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
|
||
() => loadBackgroundMotionMode ())
|
||
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, RecoveredCandidateState>
|
||
> (() => recoveredCandidateMapFromStored (
|
||
storedGame?.recoveredCandidatePosts ?? [],
|
||
storedGame?.scores ?? []))
|
||
const [recoveryStepCount, setRecoveryStepCount] = useState (
|
||
storedGame?.recoveryStepCount ?? 0)
|
||
const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
|
||
() => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
|
||
const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
|
||
() => {
|
||
if ((storedGame?.askedQuestionBank?.length ?? 0) > 0)
|
||
return []
|
||
|
||
return 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 [learnedExampleCount, setLearnedExampleCount] = useState<number | null> (
|
||
storedGame?.learnedExampleCount ?? null)
|
||
const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] =
|
||
useState<QuestionSuggestionEntryMode> (
|
||
storedGame?.questionSuggestionEntryMode ?? 'search')
|
||
const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState (
|
||
storedGame?.questionSuggestionSearch ?? '')
|
||
const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] =
|
||
useState<number | null> (storedGame?.questionSuggestionSelectedId ?? 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 != null))
|
||
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,
|
||
learnedExampleCount,
|
||
gameSeed,
|
||
questionSuggestionEntryMode,
|
||
questionSuggestionSearch,
|
||
questionSuggestionSelectedId,
|
||
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,
|
||
learnedExampleCount,
|
||
gameSeed,
|
||
questionSuggestionEntryMode,
|
||
questionSuggestionSearch,
|
||
questionSuggestionSelectedId,
|
||
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])
|
||
|
||
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,
|
||
scores }),
|
||
[posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex,
|
||
answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores])
|
||
const scoringQuestions = useMemo (() => {
|
||
return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank])
|
||
}, [acceptedQuestions, askedQuestionBank])
|
||
const scoringQuestionById = useMemo (
|
||
() => new Map (scoringQuestions.map (question => [question.id, question])),
|
||
[scoringQuestions])
|
||
const searchableSuggestedQuestions = useMemo (
|
||
() => searchedQuestionsFor (acceptedQuestions, questionSuggestionSearch),
|
||
[acceptedQuestions, questionSuggestionSearch])
|
||
const selectedSuggestedQuestion = useMemo (
|
||
() => acceptedQuestions.find (
|
||
question => question.recordId === questionSuggestionSelectedId)
|
||
?? null,
|
||
[acceptedQuestions, questionSuggestionSelectedId])
|
||
const canSubmitQuestionSuggestion = useMemo (() => {
|
||
if (!(canPersistGame) || reviewCorrectPostId == null)
|
||
return false
|
||
|
||
if (questionSuggestionEntryMode === 'search')
|
||
return selectedSuggestedQuestion != null
|
||
|
||
return questionSuggestion.trim () !== ''
|
||
}, [
|
||
canPersistGame,
|
||
reviewCorrectPostId,
|
||
questionSuggestionEntryMode,
|
||
selectedSuggestedQuestion,
|
||
questionSuggestion])
|
||
const recentFirstQuestionPenaltyById = useMemo (() => {
|
||
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
|
||
}, [recentGames])
|
||
const userPriorWeights = useMemo (
|
||
() => userPriorWeightsFor (posts, recentGames),
|
||
[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,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount,
|
||
winningRunTargetId,
|
||
winningRunStartAnswerCount }),
|
||
[posts, eligiblePosts, availablePosts, acceptedQuestions, scores,
|
||
answers, askedIds, gameSeed, recentFirstQuestionPenaltyById,
|
||
userPriorWeights, materialIndex, acceptedQuestionMatchIndex,
|
||
lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount])
|
||
const winningRunTargetPost = useMemo (
|
||
() => {
|
||
if (questionPlan.winningRunTargetId == null)
|
||
return null
|
||
|
||
return 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 (
|
||
() => {
|
||
if (!(isAdmin) || !(currentQuestion))
|
||
return []
|
||
|
||
return 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 = (() => {
|
||
if (resultWon != null)
|
||
return resultWon
|
||
if (reviewGuessedPostId == null || reviewCorrectPostId == null)
|
||
return null
|
||
|
||
return reviewGuessedPostId === reviewCorrectPostId
|
||
}) ()
|
||
const effectiveBackgroundMotionMode = (() => {
|
||
if (backgroundMotionMode === 'off')
|
||
return 'off'
|
||
if (prefersReducedMotion)
|
||
return 'calm'
|
||
|
||
return backgroundMotionMode
|
||
}) ()
|
||
const backgroundPosts = useMemo (
|
||
() => backgroundPostsFor ({
|
||
phase,
|
||
eligiblePosts,
|
||
availablePosts,
|
||
displayedGuess,
|
||
reviewCorrectPost,
|
||
reviewGuessedPost }),
|
||
[phase, eligiblePosts, availablePosts, displayedGuess, reviewCorrectPost,
|
||
reviewGuessedPost])
|
||
const backgroundVisualSeed =
|
||
`${ 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)
|
||
setLearnedExampleCount (data.learnedExampleCount)
|
||
setResultWon (variables.guessedPostId === variables.correctPostId)}})
|
||
const questionSuggestionMutation = useMutation ({
|
||
mutationFn: saveGekanatorQuestionSuggestion,
|
||
onSuccess: async data => {
|
||
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
|
||
setQuestionSuggestionCount (data.count)
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (null)
|
||
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.slice (0, 2))
|
||
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)
|
||
setLearnedExampleCount (null)
|
||
setGameSeed (createGameSeed ())
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (null)
|
||
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, RecoveredCandidateState>
|
||
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 = (() => {
|
||
if (allowPreQuestionRecovery)
|
||
return nextAnswers.length
|
||
|
||
return 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,
|
||
scores: recoveredScores })
|
||
let recoveredQuestions = buildQuestionsForCandidateIds ({
|
||
candidateIds: recoveredEligiblePosts.map (post => post.id),
|
||
materialIndex,
|
||
acceptedQuestions,
|
||
mode: 'split' })
|
||
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,
|
||
scores: recoveredScores })
|
||
recoveredQuestions = buildQuestionsForCandidateIds ({
|
||
candidateIds: recoveredEligiblePosts.map (post => post.id),
|
||
materialIndex,
|
||
acceptedQuestions,
|
||
mode: 'split' })
|
||
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,
|
||
materialIndex,
|
||
matchIndex: acceptedQuestionMatchIndex })
|
||
const fallbackQuestion = nextQuestion?.question ?? 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,
|
||
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,
|
||
questionPurpose: questionPlan.questionPurpose,
|
||
effectiveQuestion: questionPlan.effectiveQuestion,
|
||
learningQuestion: questionPlan.learningQuestion,
|
||
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,
|
||
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,
|
||
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 = (() => {
|
||
if (phase === 'end' || phase === 'review')
|
||
return reviewGuessedPostId
|
||
if (phase === 'continue')
|
||
return lastRejectedGuessId ?? displayedGuess?.id
|
||
|
||
return displayedGuess?.id ?? lastRejectedGuessId
|
||
}) ()
|
||
if (!(guessedPostId))
|
||
return
|
||
|
||
saveMutation.reset ()
|
||
questionSuggestionMutation.reset ()
|
||
resetExtraQuestionState ()
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
setLearnedExampleCount (null)
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (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)
|
||
setLearnedExampleCount (null)
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (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 (saveMutation.isError)
|
||
{
|
||
reset ()
|
||
return
|
||
}
|
||
|
||
if (!(canPersistGame))
|
||
{
|
||
reset ()
|
||
return
|
||
}
|
||
|
||
saveReviewedResult (reset)
|
||
}
|
||
|
||
const saveAndLearn = () => {
|
||
if (!(canPersistGame))
|
||
{
|
||
setPhase ('end')
|
||
return
|
||
}
|
||
|
||
resetExtraQuestionState ()
|
||
saveReviewedResult (() => setPhase ('end'))
|
||
}
|
||
|
||
const submitQuestionSuggestion = () => {
|
||
const questionText = questionSuggestion.trim ()
|
||
const selectedQuestion = selectedSuggestedQuestion
|
||
if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending)
|
||
return
|
||
|
||
saveReviewedResult (gekanatorGameId => {
|
||
questionSuggestionMutation.mutate ({
|
||
gekanatorGameId,
|
||
existingQuestionId: selectedQuestion?.recordId,
|
||
questionText: selectedQuestion ? undefined : 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,
|
||
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)
|
||
setLearnedExampleCount (null)
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (null)
|
||
resetExtraQuestionState ()
|
||
setAnswers (answers.map ((answer, i) =>
|
||
i === index ? { ...answer, answer: value } : answer))
|
||
}
|
||
|
||
const selectCorrectPost = (post: Post) => {
|
||
if (phase === 'review')
|
||
{
|
||
setSaved (false)
|
||
setSavedGameId (null)
|
||
setLearnedExampleCount (null)
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestionSearch ('')
|
||
setQuestionSuggestionSelectedId (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.slice (0, 2))
|
||
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 introLoadingMessage =
|
||
phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……'
|
||
const questionSuggestionTitle =
|
||
questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。'
|
||
|
||
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>
|
||
|
||
<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">
|
||
<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 }) => {
|
||
const modeClass =
|
||
backgroundMotionMode === mode
|
||
? 'bg-pink-600 text-white'
|
||
: 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900'
|
||
|
||
return (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
className={cn (
|
||
'rounded-full px-2.5 py-1 transition-colors',
|
||
modeClass)}
|
||
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>
|
||
{introLoadingMessage}
|
||
</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}/>}
|
||
{!(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}/>}
|
||
{!(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">{questionSuggestionTitle}</p>
|
||
</div>
|
||
{questionSuggestionEntryMode === 'search' && (
|
||
<>
|
||
<label className="block space-y-2">
|
||
<span className="font-bold">既存質問を検索</span>
|
||
<input
|
||
value={questionSuggestionSearch}
|
||
onChange={ev => {
|
||
setQuestionSuggestionSearch (ev.target.value)
|
||
setQuestionSuggestionSelectedId (null)
|
||
}}
|
||
className="w-full rounded border border-yellow-300 bg-white px-3 py-2
|
||
dark:border-red-700 dark:bg-red-950"
|
||
placeholder="バーカ?wwwwwwwwwwwww"/>
|
||
</label>
|
||
{searchableSuggestedQuestions.length > 0 && (
|
||
<>
|
||
<div className="space-y-2">
|
||
<div className="font-bold">既存質問候補</div>
|
||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||
{searchableSuggestedQuestions.map (question => {
|
||
const questionClass =
|
||
questionSuggestionSelectedId === (question.recordId ?? null)
|
||
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
|
||
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900'
|
||
|
||
return (
|
||
<button
|
||
key={question.id}
|
||
type="button"
|
||
className={cn (
|
||
'block w-full rounded border px-3 py-2 text-left',
|
||
questionClass)}
|
||
onClick={() => {
|
||
setQuestionSuggestionSelectedId (question.recordId ?? null)
|
||
setQuestionSuggestion ('')
|
||
}}>
|
||
<div className="font-bold">{question.text}</div>
|
||
</button>)
|
||
})}
|
||
</div>
|
||
</div>
|
||
{selectedSuggestedQuestion && (
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
既存質問を選択中: {selectedSuggestedQuestion.text}
|
||
</p>)}
|
||
</>)}
|
||
</>)}
|
||
{questionSuggestionEntryMode === 'new' && (
|
||
<div className="space-y-2">
|
||
<div className="font-bold">新規質問</div>
|
||
<textarea
|
||
value={questionSuggestion}
|
||
onChange={ev => {
|
||
setQuestionSuggestion (ev.target.value)
|
||
if (ev.target.value.trim () !== '')
|
||
setQuestionSuggestionSelectedId (null)
|
||
}}
|
||
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"
|
||
placeholder="おっと、彼は逃げている?"/>
|
||
</div>)}
|
||
{(canSubmitQuestionSuggestion
|
||
&& !(saveMutation.isPending)
|
||
&& !(questionSuggestionMutation.isPending))
|
||
&& (
|
||
<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>)}
|
||
{questionSuggestionEntryMode !== 'new' && (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||
適切な質問がない場合は新規追加してください。
|
||
</p>
|
||
<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={() => {
|
||
setQuestionSuggestionEntryMode ('new')
|
||
setQuestionSuggestionSelectedId (null)
|
||
setQuestionSuggestionSearch ('')
|
||
}}>
|
||
新規追加
|
||
</button>
|
||
</div>
|
||
</div>)}
|
||
<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={() => {
|
||
if (questionSuggestionEntryMode === 'new')
|
||
{
|
||
setQuestionSuggestionEntryMode ('search')
|
||
setQuestionSuggestion ('')
|
||
}
|
||
else
|
||
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={!(canSubmitQuestionSuggestion)
|
||
|| saveMutation.isPending
|
||
|| questionSuggestionMutation.isPending}
|
||
onClick={() => {
|
||
submitQuestionSuggestion ()
|
||
setPhase ('end')
|
||
}}>
|
||
保存
|
||
</button>
|
||
</div>
|
||
{!(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">追加で質問に答えてください。</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 => {
|
||
const optionClass =
|
||
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'
|
||
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className={cn (
|
||
'rounded border px-3 py-2',
|
||
optionClass)}
|
||
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
|