ファイル
btrc-hub/frontend/src/pages/GekanatorPage.tsx
T
みてるぞ 776dea87d9 素材管理 (#306) (#381)
開発環境では、**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>
2026-06-28 06:35:53 +09:00

5691 行
173 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
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&emsp;<ruby>鹿<rt></rt></ruby>!</>
const loseDialogue =
<>!&emsp;<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