コミットを比較

...

3 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 62d0830aec #371 2026-06-16 23:18:11 +09:00
みてるぞ cda90b76d2 #371 2026-06-16 22:31:03 +09:00
みてるぞ 673a5dbd23 #371 2026-06-16 21:52:10 +09:00
6個のファイルの変更635行の追加302行の削除
+12 -1
ファイルの表示
@@ -192,7 +192,10 @@ class GekanatorGamesController < ApplicationController
answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown'
question = accepted_questions[answer['question_id'].to_s]
question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question)
example =
@@ -251,6 +254,7 @@ class GekanatorGamesController < ApplicationController
end
def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag'
@@ -258,4 +262,11 @@ class GekanatorGamesController < ApplicationController
key = condition[:key].to_s
!key.start_with?('nico:')
end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end
+152 -3
ファイルの表示
@@ -151,6 +151,154 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(response).to have_http_status(:unauthorized)
end
it 'learns accepted non-nico tag answers from camelCase main game logs' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: 'MAD 要素がある?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'meme:MAD' },
created_by: admin
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:missing',
question_text: '存在しない質問?',
answer: 'yes',
original_answer: 'yes'
},
{
questionId: 'tag:meme:MAD',
question_text: 'MAD 要素がある?',
answer: 'unknown',
original_answer: 'unknown'
}
]
}
}.to change { GekanatorQuestionExample.count }.by(1)
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
example = GekanatorQuestionExample.last
expect(example).to have_attributes(
gekanator_question_id: tag_question.id,
post_id: correct_post.id,
user_id: admin.id,
answer: 'yes',
source: 'post_game_answer'
)
expect(example.gekanator_game_id).to eq(json['id'])
end
it 'does not learn fact questions or nico tag questions from main game logs' do
sign_in_as admin
[
{
text: 'example.com 由来?',
kind: 'source',
condition: { type: 'source', host: 'example.com' }
},
{
text: '題名に結束バンドを含む?',
kind: 'title',
condition: { type: 'title-contains', text: '結束バンド' }
},
{
text: '2024 年投稿?',
kind: 'original_date',
condition: { type: 'original-year', year: 2024 }
},
{
text: 'ニコニコにぼっちタグ?',
kind: 'tag',
condition: { type: 'tag', key: 'nico:ぼっち' }
}
].each do |attributes|
GekanatorQuestion.create!(
text: attributes[:text],
kind: attributes[:kind],
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: attributes[:condition],
created_by: admin
)
end
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{ question_id: 'source:example.com', answer: 'yes' },
{ question_id: 'title:contains:結束バンド', answer: 'yes' },
{ question_id: 'original-year:2024', answer: 'yes' },
{ question_id: 'tag:nico:ぼっち', answer: 'yes' }
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(0)
end
it 'updates an existing main game example instead of duplicating it' do
sign_in_as admin
tag_question = GekanatorQuestion.create!(
text: '喜多ちゃんが関係してる?',
kind: 'tag',
source: 'admin_curated',
status: 'accepted',
priority_weight: 1.0,
condition: { type: 'tag', key: 'character:喜多郁代' },
created_by: admin
)
existing = GekanatorQuestionExample.create!(
gekanator_question: tag_question,
post: correct_post,
user: admin,
answer: 'no',
source: 'post_game_answer',
weight: 1.0
)
expect {
post '/gekanator/games', params: {
guessed_post_id: guessed_post.id,
correct_post_id: correct_post.id,
answers: [
{
question_id: 'tag:character:喜多郁代',
answer: 'yes',
original_answer: 'yes'
}
]
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:created)
expect(json['learned_example_count']).to eq(1)
expect(existing.reload.answer).to eq('yes')
expect(existing.sample_count).to eq(2)
end
end
describe 'POST /gekanator/question_suggestions' do
@@ -249,7 +397,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
expect(GekanatorQuestionSuggestion.last.processed).to eq(false)
end
it 'limits suggestions to three per game' do
it 'allows more than three suggestions per game' do
sign_in_as admin
3.times do |i|
@@ -267,9 +415,10 @@ RSpec.describe 'Gekanator learning API', type: :request do
question_text: 'fourth question?',
answer: 'yes'
}
}.not_to change { GekanatorQuestionSuggestion.count }
}.to change { GekanatorQuestionSuggestion.count }.by(1)
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:created)
expect(json['count']).to eq(4)
end
it 'allows a non-admin user to suggest a question for their own game' do
+75 -3
ファイルの表示
@@ -51,6 +51,21 @@ const postSimilarityQuestion = (
})
const sourceQuestion = (
host: string,
): GekanatorQuestion => ({
id: `source:${ host }`,
text: `${ host }?`,
kind: 'source',
condition: {
type: 'source',
host },
source: 'default',
priorityWeight: 1,
test: candidate => new URL (candidate.url).hostname === host,
})
const answer = (
question: GekanatorQuestion,
value: GekanatorAnswerValue,
@@ -64,7 +79,7 @@ const answer = (
describe('candidatePostsFor', () => {
it('lets recovered candidates ignore old answers but not later answers', () => {
it('does not hard-filter semantic post_similarity answers', () => {
const posts = [post (1), post (2), post (3)]
const oldQuestion = postSimilarityQuestion ('old', {
1: 'no',
@@ -77,6 +92,29 @@ describe('candidatePostsFor', () => {
3: 'yes',
})
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
answers: [answer (oldQuestion, 'yes'), answer (laterQuestion, 'yes')],
softenedQuestionIds: new Set (),
rejectedPostIds: new Set (),
recoveredCandidatePosts: new Map ([
[1, 1],
[3, 1],
]) })
expect(candidates.map (candidate => candidate.id)).toEqual ([1, 2, 3])
})
it('lets recovered candidates ignore old fact answers but not later fact answers', () => {
const posts = [
{ ...post (1), url: 'https://other.example/posts/1' },
post (2),
{ ...post (3), url: 'https://example.com/posts/3' },
]
const oldQuestion = sourceQuestion ('old.example.com')
const laterQuestion = sourceQuestion ('example.com')
const candidates = candidatePostsFor ({
posts,
questions: [oldQuestion, laterQuestion],
@@ -112,7 +150,7 @@ describe('candidatePostsFor', () => {
describe('hardFilteredPostsForAnswer', () => {
it('returns zero candidates without falling back to the original pool', () => {
it('keeps the original pool for semantic post_similarity answers', () => {
const posts = [post (1), post (2)]
const question = postSimilarityQuestion ('question', {
1: 'yes',
@@ -123,7 +161,41 @@ describe('hardFilteredPostsForAnswer', () => {
posts,
question,
answer: 'no',
})).toEqual ([])
})).toEqual (posts)
})
it('hard-filters fact answers only for yes and no', () => {
const posts = [
{ ...post (1), url: 'https://example.com/posts/1' },
{ ...post (2), url: 'https://other.example/posts/2' },
]
const question = sourceQuestion ('example.com')
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'yes',
}).map (candidate => candidate.id)).toEqual ([1])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'no',
}).map (candidate => candidate.id)).toEqual ([2])
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'partial',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'probably_no',
})).toEqual (posts)
expect(hardFilteredPostsForAnswer ({
posts,
question,
answer: 'unknown',
})).toEqual (posts)
})
})
+1 -1
ファイルの表示
@@ -86,7 +86,7 @@ export const hardFilteredPostsForAnswer = ({
if (!(questionIsFactLikeForHardFiltering (question)))
return posts
if (answer === 'unknown')
if (!(answer === 'yes' || answer === 'no'))
return posts
return posts.filter (post => {
+54
ファイルの表示
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { isQuestionHardFilteredAfterAnswers } from '@/lib/gekanatorQuestionFilters'
@@ -49,6 +52,57 @@ const blocked = (
isQuestionHardFilteredAfterAnswers (question (candidate), [answer (previous, value)])
const gekanatorPageSource = readFileSync (
resolve (process.cwd (), 'src/pages/GekanatorPage.tsx'),
'utf8')
const gekanatorBackdropSource = gekanatorPageSource.slice (
gekanatorPageSource.indexOf ('const GekanatorBackdrop'),
gekanatorPageSource.indexOf ('const expectedAnswerFor'))
describe('GekanatorBackdrop regression structure', () => {
it('keeps displayedBackdropMode as the render-time source of truth', () => {
expect(gekanatorBackdropSource).not.toContain ('isLeavingGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('renderBackdropMode')
expect(gekanatorBackdropSource).not.toContain ('renderWinningRunCount')
expect(gekanatorBackdropSource).not.toContain ('renderThumbnails')
expect(gekanatorBackdropSource).not.toContain ('renderIsCrossfading')
expect(gekanatorBackdropSource).toContain (
"const renderedSettings = settingsForMode (displayedBackdropMode)")
expect(gekanatorBackdropSource).toContain (
'scaleForMode (displayedBackdropMode, displayedWinningRunCount)')
expect(gekanatorBackdropSource).toContain (
"backdropMode === 'guess' || displayedBackdropMode === 'guess'")
})
it('does not split guess into a separate renderer or force a remount', () => {
expect(gekanatorBackdropSource).not.toContain ('renderStaticGuessBackdrop')
expect(gekanatorBackdropSource).not.toContain ('guessZoomAnimationKey')
expect(gekanatorBackdropSource).not.toContain ('shouldAnimateGuessZoomIn')
expect(gekanatorBackdropSource).not.toContain ('previousBackdropModeRef')
expect(gekanatorBackdropSource).not.toContain (
'if (isGuessPresentation && guessThumbnail)')
})
it('keeps tile keys independent from backdrop mode', () => {
expect(gekanatorBackdropSource).toContain ('key={duplicate}')
expect(gekanatorBackdropSource).toContain ('key={`${ duplicate }:${ index }`}')
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*mode/)
expect(gekanatorBackdropSource).not.toMatch (/key=\{`\$\{\s*displayedBackdropMode/)
})
it('keeps guess on the shared scale, x, and y animation path', () => {
expect(gekanatorBackdropSource).toContain ('animate={{ scale: renderedScale')
expect(gekanatorBackdropSource).toContain (
"x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%'")
expect(gekanatorBackdropSource).toContain (
"y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%'")
})
})
describe('isQuestionHardFilteredAfterAnswers', () => {
it('blocks only contradictory or redundant month questions after a yes answer', () => {
const previous: GekanatorQuestionCondition = { type: 'original-month', month: 12 }
+341 -294
ファイルの表示
@@ -110,6 +110,7 @@ type StoredGekanatorGame = {
savedGameId: number | null
learnedExampleCount?: number | null
gameSeed?: string
questionSuggestionEntryMode?: 'search' | 'new'
questionSuggestionSearch?: string
questionSuggestionSelectedId?: number | null
questionSuggestion: string
@@ -141,6 +142,10 @@ type QuestionBuildMode =
| 'split'
| 'confirmation'
type QuestionSuggestionEntryMode =
| 'search'
| 'new'
type MascotState =
| 'idle'
| 'thinking_far'
@@ -256,6 +261,7 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
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 => ({
@@ -1125,17 +1131,31 @@ const applyQuestionAnswerDeltaToScores = ({
if (!(questionUsesPostSimilarityGraphForScoring (question)))
return
// `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
nextScores.set (
edge.targetPostId,
(nextScores.get (edge.targetPostId) ?? 0) + baseDelta * edge.cos)
const propagatedDelta = baseDelta * edge.cos
const current = propagatedDeltaByPostId.get (edge.targetPostId)
if (current === undefined || 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 = (
@@ -1294,7 +1314,7 @@ const buildQuestionsForCandidateIds = (
.forEach (([term]) => {
addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: String (term) },
text: `題名に「${ term }」が含まれる?`,
text: `タイトルに「${ term }」が含まれる?`,
kind: 'title',
priorityWeight: .99,
materialIndex }))
@@ -1313,7 +1333,7 @@ const buildQuestionsForCandidateIds = (
if (asciiCount > 0 && asciiCount < total)
addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?',
text: 'タイトルに英数字が混じっている?',
kind: 'title',
priorityWeight: .68,
materialIndex }))
@@ -1367,7 +1387,7 @@ const buildQuestionsForCandidateIds = (
.forEach (term => {
addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: term },
text: `題名に「${ term }」が含まれる?`,
text: `タイトルに「${ term }」が含まれる?`,
kind: 'title',
priorityWeight: .99,
materialIndex }))
@@ -1386,7 +1406,7 @@ const buildQuestionsForCandidateIds = (
if (materialIndex.titleAsciiPostIds.has (targetPostId))
addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?',
text: 'タイトルに英数字が混じっている?',
kind: 'title',
priorityWeight: .68,
materialIndex }))
@@ -1415,6 +1435,8 @@ const candidatePostsForState = ({
recoveredCandidatePosts: 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))
@@ -1427,7 +1449,7 @@ const candidatePostsForState = ({
return true
if (softenedQuestionIds.has (answer.questionId))
return true
if (!(answer.answer === 'yes' || answer.answer === 'no'))
if (!(answerAllowsHardFilter (answer.answer)))
return true
const question = questionById.get (answer.questionId)
@@ -2717,6 +2739,7 @@ const GekanatorBackdrop: FC<{
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)
@@ -2730,16 +2753,18 @@ const GekanatorBackdrop: FC<{
nextThumbnails)
const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed)
const [isFlippingTiles, setIsFlippingTiles] = useState (false)
const [isCrossfading, setIsCrossfading] = useState (false)
const renderedSettings = settingsForMode (displayedBackdropMode)
const renderedTileCount =
renderedSettings.columns * renderedSettings.rows
const renderedTileCount = renderedSettings.columns * renderedSettings.rows
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount)
const isGuessPresentation =
phase === 'guess' || backdropMode === 'guess' || displayedBackdropMode === 'guess'
const crossfadeDuration = motionMode === 'calm' ? .95 : .75
backdropMode === 'guess' || displayedBackdropMode === 'guess'
useEffect (() => {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
if (motionMode === 'off')
return
@@ -2752,9 +2777,11 @@ const GekanatorBackdrop: FC<{
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])
@@ -2771,13 +2798,21 @@ const GekanatorBackdrop: FC<{
if (motionMode === 'off' || nextThumbnails.length === 0)
{
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0)
y.set (0)
return
}
if (isGuessPresentation)
return
{
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0)
y.set (0)
return
}
const speed = 33.333333 / marqueeDuration
let animationFrame: number
@@ -2795,8 +2830,7 @@ const GekanatorBackdrop: FC<{
animationFrame = window.requestAnimationFrame (tick)
return () => window.cancelAnimationFrame (animationFrame)
}, [
x,
}, [x,
y,
marqueeDuration,
motionMode,
@@ -2809,77 +2843,73 @@ const GekanatorBackdrop: FC<{
setActiveDirection (nextDirection)
}
if (flipTimerRef.current !== null) {
window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null
}
if (flipTimerRef.current !== null)
{
window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null
}
if (motionMode === 'off') {
applyDirection ()
setIsFlippingTiles (false)
setIsCrossfading (false)
setFlipVisualSeed (visualSeed)
return
}
if (motionMode === 'off')
{
applyDirection ()
setIsFlippingTiles (false)
setFlipVisualSeed (visualSeed)
return
}
if (backdropMode === 'guess' && guessThumbnail) {
setIsFlippingTiles (false)
setIsCrossfading (false)
setDisplayedBackdropMode ('guess')
setDisplayedWinningRunCount (winningRunQuestionCount)
setDisplayedThumbnails (nextThumbnails)
setFromThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails)
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)
setIsCrossfading (false)
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)
setIsCrossfading (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
}
&& 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)
const shouldCrossfade =
displayedBackdropMode === 'winning_run' && backdropMode === 'normal'
setIsCrossfading (shouldCrossfade)
setIsFlippingTiles (!(shouldCrossfade))
setIsFlippingTiles (true)
flipTimerRef.current = window.setTimeout (() => {
setDisplayedBackdropMode (backdropMode)
@@ -2888,20 +2918,19 @@ const GekanatorBackdrop: FC<{
setFromThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails)
setIsFlippingTiles (false)
setIsCrossfading (false)
applyDirection ()
setFlipVisualSeed (visualSeed)
flipTimerRef.current = null
}, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000)
}, tileFlipDuration * 1000)
return () => {
if (flipTimerRef.current !== null) {
window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null
}
if (flipTimerRef.current !== null)
{
window.clearTimeout (flipTimerRef.current)
flipTimerRef.current = null
}
}
}, [
motionMode,
}, [motionMode,
backdropMode,
displayedBackdropMode,
guessThumbnail,
@@ -2912,124 +2941,10 @@ const GekanatorBackdrop: FC<{
visualSeed,
activeDirection,
winningRunQuestionCount,
crossfadeDuration,
tileFlipDuration,
x,
y])
const renderTileSet = ({
mode,
thumbnails,
settings,
tileCount,
scale,
opacity,
withFlip,
}: {
mode: 'normal' | 'winning_run' | 'guess'
thumbnails: string[]
settings: { columns: number; rows: number; opacity: number }
tileCount: number
scale: number
opacity?: number
withFlip?: boolean
}) => (
<motion.div
className="absolute inset-0"
initial={opacity === undefined
? undefined
: {
opacity: opacity === 0 ? 1 : 0,
scale,
x: mode === 'guess' ? guessFocusOffset.x : '0%',
y: mode === 'guess' ? guessFocusOffset.y : '0%' }}
animate={{ opacity: opacity ?? 1, scale,
x: mode === 'guess' ? guessFocusOffset.x : '0%',
y: mode === 'guess' ? guessFocusOffset.y : '0%' }}
transition={(mode === 'winning_run' || mode === 'guess')
? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] }
: { duration: .2 }}>
{Array.from ({ length: 9 }, (_, duplicate) => {
const column = duplicate % 3
const row = Math.floor (duplicate / 3)
return (
<motion.div
key={`${ mode }:${ duplicate }`}
className="absolute grid overflow-hidden"
layout={mode !== 'normal'}
style={{
left: `${ column * 33.333333 }%`,
top: `${ row * 33.333333 }%`,
width: '33.333333%',
height: '33.333333%',
gridTemplateColumns:
`repeat(${ settings.columns }, minmax(0, 1fr))`,
gridTemplateRows:
`repeat(${ settings.rows }, minmax(0, 1fr))` }}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
{Array.from ({ length: tileCount }, (_, index) => {
const currentThumbnail =
thumbnails[index % Math.max (thumbnails.length, 1)]
const frontThumbnail =
withFlip
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
: currentThumbnail
const backThumbnail =
withFlip
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
: currentThumbnail
const thumbnail = currentThumbnail
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
return null
return (
<motion.div
key={`${ mode }:${ duplicate }:${ index }`}
className="relative overflow-hidden"
layout={mode !== 'normal'}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
style={{ perspective: 1600 }}>
{(mode !== 'normal' || !(withFlip))
? (
<img
src={['intro', 'end'].includes (phase)
? mascotAsset
: thumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: settings.opacity }}/>)
: (
<motion.div
className="absolute inset-0"
initial={{ rotateY: 0 }}
animate={{ rotateY: 180 }}
transition={{
duration: tileFlipDuration,
ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }}>
<img
src={backThumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
backfaceVisibility: 'hidden',
opacity: settings.opacity,
transform: 'rotateY(180deg)' }}/>
<img
src={frontThumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
backfaceVisibility: 'hidden',
opacity: settings.opacity }}/>
</motion.div>)}
</motion.div>)
})}
</motion.div>)
})}
</motion.div>)
if (motionMode === 'off' || nextThumbnails.length === 0)
return (
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
@@ -3045,32 +2960,99 @@ const GekanatorBackdrop: FC<{
width: 'calc(max(100vw, 100vh) * 3)',
height: 'calc(max(100vw, 100vh) * 3)' }}>
<motion.div
className="relative h-full w-full">
{isCrossfading
? (
<>
{renderTileSet ({
mode: displayedBackdropMode,
thumbnails: fromThumbnails,
settings: renderedSettings,
tileCount: renderedTileCount,
scale: renderedScale,
opacity: 0 })}
{renderTileSet ({
mode: backdropMode,
thumbnails: toThumbnails,
settings: targetSettings,
tileCount: targetTileCount,
scale: scaleForMode (backdropMode, winningRunQuestionCount),
opacity: 1 })}
</>)
: renderTileSet ({
mode: displayedBackdropMode,
thumbnails: displayedThumbnails,
settings: renderedSettings,
tileCount: renderedTileCount,
scale: renderedScale,
withFlip: isFlippingTiles })}
className="relative h-full w-full"
animate={{ scale: renderedScale,
x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
transition={(displayedBackdropMode === 'winning_run'
|| displayedBackdropMode === 'guess')
? { duration: motionMode === 'calm' ? .95 : .75,
ease: [.16, 1, .3, 1] }
: { duration: .2 }}>
{Array.from ({ length: 9 }, (_, duplicate) => {
const column = duplicate % 3
const row = Math.floor (duplicate / 3)
return (
<motion.div
key={duplicate}
className="absolute grid overflow-hidden"
layout={displayedBackdropMode !== 'normal'}
style={{
left: `${ column * 33.333333 }%`,
top: `${ row * 33.333333 }%`,
width: '33.333333%',
height: '33.333333%',
gridTemplateColumns:
`repeat(${ renderedSettings.columns }, minmax(0, 1fr))`,
gridTemplateRows:
`repeat(${ renderedSettings.rows }, minmax(0, 1fr))` }}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
{Array.from ({ length: renderedTileCount }, (_, index) => {
const currentThumbnail =
displayedThumbnails[
index % Math.max (displayedThumbnails.length, 1)]
const frontThumbnail =
isFlippingTiles
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
: currentThumbnail
const backThumbnail =
isFlippingTiles
? toThumbnails[index % Math.max (toThumbnails.length, 1)]
: currentThumbnail
const thumbnail =
displayedBackdropMode === 'winning_run'
|| displayedBackdropMode === 'guess'
? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
: currentThumbnail
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
return null
return (
<motion.div
key={`${ duplicate }:${ index }`}
className="relative overflow-hidden"
layout={displayedBackdropMode !== 'normal'}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
style={{ perspective: 1600 }}>
{(displayedBackdropMode !== 'normal' || !(isFlippingTiles))
? (
<img
src={['intro', 'end'].includes (phase)
? mascotAsset
: thumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: renderedSettings.opacity }}/>)
: (
<motion.div
className="absolute inset-0"
initial={{ rotateY: 0 }}
animate={{ rotateY: 180 }}
transition={{
duration: tileFlipDuration,
ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }}>
<img
src={backThumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
backfaceVisibility: 'hidden',
opacity: renderedSettings.opacity,
transform: 'rotateY(180deg)' }}/>
<img
src={frontThumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
backfaceVisibility: 'hidden',
opacity: renderedSettings.opacity }}/>
</motion.div>)}
</motion.div>)
})}
</motion.div>)
})}
</motion.div>
</motion.div>
</div>
@@ -3150,6 +3132,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
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] =
@@ -3190,11 +3175,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
[posts, acceptedQuestions])
useEffect (() => {
if (
posts.length === 0
if (posts.length === 0
|| storedAskedQuestionBankIds.length === 0
|| !(acceptedQuestionsFetched)
)
|| !(acceptedQuestionsFetched))
return
const questionById = new Map (
@@ -3207,8 +3190,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
.map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([])
}, [
posts,
}, [posts,
storedAskedQuestionBankIds,
acceptedQuestionsFetched,
askedQuestionBank,
@@ -3250,6 +3232,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId,
learnedExampleCount,
gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch,
questionSuggestionSelectedId,
questionSuggestion,
@@ -3293,6 +3276,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId,
learnedExampleCount,
gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch,
questionSuggestionSelectedId,
questionSuggestion,
@@ -3355,6 +3339,28 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
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 canShowNewQuestionSuggestionButton = useMemo (() => {
return questionSuggestionEntryMode === 'search'
&& questionSuggestionSearch.trim () !== ''
&& searchableSuggestedQuestions.length === 0
}, [
questionSuggestionEntryMode,
questionSuggestionSearch,
searchableSuggestedQuestions.length])
const recentFirstQuestionPenaltyById = useMemo (() => {
const penalties = new Map<string, number> ()
@@ -3494,6 +3500,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
onSuccess: async data => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setQuestionSuggestionCount (data.count)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('')
@@ -3544,6 +3551,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSavedGameId (null)
setLearnedExampleCount (null)
setGameSeed (createGameSeed ())
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('')
@@ -3876,6 +3884,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false)
setSavedGameId (null)
setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setReviewGuessedPostId (guessedPostId)
@@ -3895,6 +3904,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false)
setSavedGameId (null)
setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
setSelectingCorrectPost (false)
@@ -3925,6 +3935,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
}
const saveAndReset = () => {
if (saveMutation.isError)
{
reset ()
return
}
if (!(canPersistGame))
{
reset ()
@@ -3948,11 +3964,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim ()
const selectedQuestion = selectedSuggestedQuestion
if (
!(canPersistGame)
|| (selectedQuestion === null && !(questionText))
|| questionSuggestionMutation.isPending
)
if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending)
return
saveReviewedResult (gekanatorGameId => {
@@ -4092,6 +4104,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false)
setSavedGameId (null)
setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
resetExtraQuestionState ()
@@ -4105,6 +4118,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false)
setSavedGameId (null)
setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null)
resetExtraQuestionState ()
@@ -4798,62 +4812,101 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p>
<p className="text-xl font-bold">
{questionSuggestionEntryMode === 'search'
? 'まず既存質問を探してください。'
: '新しい質問を追加します。'}
</p>
</div>
<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="質問文で検索。前方一致を優先して部分一致も出ます。"/>
</label>
{searchableSuggestedQuestions.length > 0 && (
{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="質問文で検索。前方一致を優先して部分一致も出ます。"/>
</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 => (
<button
key={question.id}
type="button"
className={cn (
'block w-full rounded border px-3 py-2 text-left',
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')}
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>)}
{canShowNewQuestionSuggestionButton && (
<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="space-y-2">
<div className="font-bold"></div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{searchableSuggestedQuestions.map (question => (
<button
key={question.id}
type="button"
className={cn (
'block w-full rounded border px-3 py-2 text-left',
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')}
onClick={() => {
setQuestionSuggestionSelectedId (question.recordId ?? null)
setQuestionSuggestion ('')
}}>
<div className="font-bold">{question.text}</div>
<div className="text-sm text-neutral-500 dark:text-neutral-400">
{question.kind}
</div>
</button>))}
<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 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={() => {
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestion ('')
}}>
</button>
</div>
</div>)}
<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>
{selectedSuggestedQuestion && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {selectedSuggestedQuestion.text}
</p>)}
<label className="block space-y-2">
<span className="font-bold">稿</span>
<select
@@ -4886,15 +4939,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
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
|| (
questionSuggestion.trim () === ''
&& selectedSuggestedQuestion === null)
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
disabled={!(canSubmitQuestionSuggestion)
|| saveMutation.isPending
|| questionSuggestionMutation.isPending}
onClick={submitQuestionSuggestion}>
</button>