From 01b063f473a254a8fc5895ac0c005aa8007ab54b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 14 Jun 2026 02:35:54 +0900 Subject: [PATCH] #361 --- AGENTS.md | 10 + frontend/src/pages/GekanatorPage.tsx | 477 ++++++++++++++------------- 2 files changed, 258 insertions(+), 229 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7690e5b..a3a0ed1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,13 @@ npm run preview - Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere. - Match existing Tailwind, component, and import alias conventions. +- In TypeScript and TSX, prefer direct comparison operators such as `===` and + `!==` over negating a comparison like `!(a === b)`. +- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for + simple unit-step counter updates. +- For user-facing Japanese text, prefer modern kana usage and natural current + phrasing over historical spellings or awkward literal wording. +- For user-facing Japanese ellipses, prefer `……` over ASCII `...`. ### Frontend TSX style @@ -179,6 +186,9 @@ npm run preview single physical line. - Always add braces around `if`, `else`, or `for` bodies when the body spans two or more physical lines, even if it is one statement. +- Do not use a leading semicolon for expression statements such as + `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards + explicitly, for example with `void`. Preferred: diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 12246b5..7edd39f 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -123,14 +123,18 @@ type RecentGameSummary = { savedAt: number } type BackgroundMotionMode = 'on' | 'calm' | 'off' + type GuessReason = | 'hard_max_questions' | 'winning_run_finished' + | 'question_count_checkpoint' | 'question_generation_stalled' + type QuestionMode = | 'winning_run' | 'normal' | null + type MascotState = | 'idle' | 'thinking_far' @@ -214,9 +218,9 @@ const normalizeStoredQuestionId = ( if (questionId.startsWith ('title:length-greater-than:')) { - const length = Number (questionId.split (':').pop ()) - if (Number.isInteger (length)) - return `title:length-at-least:${ length + 1 }` + const length = Number (questionId.split (':').pop ()) + if (Number.isInteger (length)) + return `title:length-at-least:${ length + 1 }` } return questionId @@ -227,30 +231,27 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ...game, answers: game.answers.map (answer => ({ ...answer, - questionId: normalizeStoredQuestionId ( - answer.questionId, - answer.questionCondition), - questionMode: - answer.questionMode === 'winning_run' || answer.questionMode === 'normal' - ? answer.questionMode - : undefined, - questionCondition: answer.questionCondition - ? normalizeTitleLengthCondition (answer.questionCondition) - : undefined })), + questionId: normalizeStoredQuestionId (answer.questionId, + answer.questionCondition), + questionMode: ((answer.questionMode === 'winning_run' || answer.questionMode === 'normal') + ? answer.questionMode + : undefined), + questionCondition: (answer.questionCondition + ? normalizeTitleLengthCondition (answer.questionCondition) + : undefined) })), askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)), - softenedQuestionIds: game.softenedQuestionIds.map (questionId => - normalizeStoredQuestionId (questionId)), + softenedQuestionIds: (game.softenedQuestionIds + .map (questionId => normalizeStoredQuestionId (questionId))), recoveredCandidatePosts: game.recoveredCandidatePosts ?? [], recoveryStepCount: game.recoveryStepCount ?? 0, winningRunTargetId: game.winningRunTargetId ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, - askedQuestionBank: game.askedQuestionBank?.map (question => - ({ - ...question, - id: normalizeStoredQuestionId (question.id, question.condition), - condition: normalizeTitleLengthCondition (question.condition) })), - askedQuestionBankIds: game.askedQuestionBankIds?.map (questionId => - normalizeStoredQuestionId (questionId)) }) + 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 => { @@ -290,10 +291,10 @@ const shouldReplaceMergedQuestion = ( const hashString = (value: string): number => { let hash = 2166136261 - for (let i = 0; i < value.length; i += 1) + for (let i = 0; i < value.length; ++i) { - hash ^= value.charCodeAt (i) - hash = Math.imul (hash, 16777619) + hash ^= value.charCodeAt (i) + hash = Math.imul (hash, 16777619) } return hash >>> 0 @@ -694,7 +695,7 @@ const buildMaterialIndex = ( const tagKeys = post.tags .filter (tag => - !(tag.category === 'meta') + tag.category !== 'meta' && !(tag.name.includes ('タグ希望')) && !(tag.name.includes ('bot操作'))) .map (tag => `${ tag.category }:${ tag.name }`) @@ -781,17 +782,17 @@ const indexedQuestionTextForTag = (key: string): string => { switch (category) { case 'deerjikist': - return `作者・ニジラーとして「${ label }」に関係してゐる?` + return `ニジラーとして「${ label }」に関係している?` case 'meme': - return `元ネタ・ミームとして「${ label }」に関係しさう?` + return `『${ label }』に関係しそう?` case 'character': - return `「${ label }」といふキャラクターが関係してゐる?` + return `「${ label }」というキャラクターが関係している?` case 'material': - return `素材として「${ label }」に関係してゐる?` + return `素材「${ label }」に関係している?` case 'nico': - return `ニコニコに「${ label }」といふタグが付いてゐる?` + return `ニコニコに「${ label }」といふタグがついている?` default: - return `内容として「${ label }」に関係しさう?` + return `「${ label }」が含まれる?` } } @@ -915,32 +916,30 @@ const matchingPostCountInIds = ({ if (matched.size < ids.size) matched.forEach (postId => { if (ids.has (postId)) - count += 1 + ++count }) else ids.forEach (postId => { if (matched.has (postId)) - count += 1 + ++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 matchingWeightInCandidates = ( + { candidates, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }: { candidates: { post: Post; weight: number }[] + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex }, +): number => { const matched = matchingPostIdsForQuestion ({ posts, materialIndex, @@ -952,21 +951,19 @@ const matchingWeightInCandidates = ({ sum + (matched.has (item.post.id) ? item.weight : 0), 0) } -const signatureForCandidateIds = ({ - candidateIds, - posts, - materialIndex, - matchIndex, - question, - dynamicMatchIndex, -}: { - candidateIds: number[] - posts: Post[] - materialIndex: GekanatorQuestionMaterialIndex - matchIndex: GekanatorMatchIndex - question: GekanatorQuestion - dynamicMatchIndex?: GekanatorMatchIndex -}): string => { +const signatureForCandidateIds = ( + { candidateIds, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex, }: { candidateIds: number[] + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex }, +): string => { const matched = matchingPostIdsForQuestion ({ posts, materialIndex, @@ -977,28 +974,24 @@ const signatureForCandidateIds = ({ return candidateIds.map (postId => matched.has (postId) ? '1' : '0').join ('') } -const postIdsForHardAnswer = ({ - candidateIds, - question, - answer, - posts, - materialIndex, - matchIndex, - dynamicMatchIndex, -}: { - candidateIds: number[] - question: GekanatorQuestion - answer: GekanatorAnswerValue - posts: Post[] - materialIndex: GekanatorQuestionMaterialIndex - matchIndex: GekanatorMatchIndex - dynamicMatchIndex?: GekanatorMatchIndex -}): number[] => { - if ( - answer === 'unknown' +const postIdsForHardAnswer = ( + { candidateIds, + question, + answer, + posts, + materialIndex, + matchIndex, + dynamicMatchIndex }: { candidateIds: number[] + question: GekanatorQuestion + answer: GekanatorAnswerValue + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + dynamicMatchIndex?: GekanatorMatchIndex }, +): number[] => { + if (answer === 'unknown' || answer === 'partial' - || answer === 'probably_no' - ) + || answer === 'probably_no') return candidateIds if (answer === 'yes') @@ -1026,55 +1019,48 @@ const postIdsForHardAnswer = ({ return candidateIds } -const buildIndexedQuestion = ({ - condition, +const buildIndexedQuestion = ( + { condition, + text, + kind, + priorityWeight, + materialIndex }: { + condition: Exclude + text: string + kind: GekanatorQuestionKind + priorityWeight: number + materialIndex: GekanatorQuestionMaterialIndex }, +): GekanatorQuestion => ({ + id: questionIdForCondition (condition), text, kind, + condition, + source: 'default', priorityWeight, - materialIndex, -}: { - condition: Exclude - 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 ()).has (post.id) }) + test: post => + (matchingPostIdsForCondition ({ + condition, + materialIndex }) ?? new Set ()).has (post.id) }) -const rankedEntriesForCounts = ({ - counts, - total, - cap, -}: { - counts: Map - 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 rankedEntriesForCounts = ( + { counts, total, cap }: { counts: Map + total: number + cap: number }, +): [T, number][] => + ([...counts.entries ()] + .filter (([, count]) => count > 0 && count < total) + .sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1])) + .slice (0, cap)) -const buildQuestionsForCandidateIds = ({ - candidateIds, - materialIndex, - performanceMode, - acceptedQuestions, -}: { - candidateIds: number[] - materialIndex: GekanatorQuestionMaterialIndex - performanceMode: GekanatorPerformanceMode - acceptedQuestions: GekanatorQuestion[] -}): GekanatorQuestion[] => { +const buildQuestionsForCandidateIds = ( + { candidateIds, + materialIndex, + performanceMode, + acceptedQuestions }: { candidateIds: number[] + materialIndex: GekanatorQuestionMaterialIndex + performanceMode: GekanatorPerformanceMode + acceptedQuestions: GekanatorQuestion[] }, +): GekanatorQuestion[] => { const total = candidateIds.length if (total === 0) return acceptedQuestions @@ -1109,7 +1095,7 @@ const buildQuestionsForCandidateIds = ({ const titleLength = materialIndex.titleLengthByPostId.get (postId) ?? 0 titleLengths.push (titleLength) if (materialIndex.titleAsciiPostIds.has (postId)) - asciiCount += 1 + ++asciiCount }) const tagCap = @@ -1130,7 +1116,7 @@ const buildQuestionsForCandidateIds = ({ .forEach (([host]) => { questions.push (buildIndexedQuestion ({ condition: { type: 'source', host }, - text: `${ host } の投稿を思ひ浮かべてゐる?`, + text: `${ host } の投稿を思い浮かべている?`, kind: 'source', priorityWeight: 1, materialIndex })) @@ -1180,7 +1166,7 @@ const buildQuestionsForCandidateIds = ({ if (asciiCount > 0 && asciiCount < total) questions.push (buildIndexedQuestion ({ condition: { type: 'title-has-ascii' }, - text: '題名に英数字が混じってゐる?', + text: '題名に英数字が混じっている?', kind: 'title', priorityWeight: 1, materialIndex })) @@ -1959,9 +1945,9 @@ const winningRunTagText = ( switch (category) { case 'nico': - return `ニコニコに「${ name.replace (/^nico:/, '') }」タグが付いてゐる?` + return `ニコニコに「${ name.replace (/^nico:/, '') }」タグがついている?` default: - return `「${ name }」タグが付いてゐる?` + return `「${ name }」タグがついている?` } } @@ -2019,7 +2005,7 @@ const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] ? null : { id: questionIdForCondition ({ type: 'source', host }), - text: `${ host } の投稿を思ひ浮かべてゐる?`, + text: `${ host } の投稿を思い浮かべている?`, kind: 'source', condition: { type: 'source', host }, source: 'default', @@ -2088,7 +2074,7 @@ const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] }) addQuestion ({ id: questionIdForCondition ({ type: 'title-has-ascii' }), - text: '題名に英数字が混じってゐる?', + text: '題名に英数字が混じっている?', kind: 'title', condition: { type: 'title-has-ascii' }, source: 'default', @@ -2107,7 +2093,7 @@ const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] targetPost.tags .filter (tag => - !(tag.category === 'meta') + tag.category !== 'meta' && !(tag.name.includes ('タグ希望')) && !(tag.name.includes ('bot操作'))) .slice (0, 20) @@ -2124,12 +2110,12 @@ const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] test: post => post.tags.some (candidate => candidate.category === tag.category && candidate.name === tag.name - && !(candidate.category === 'meta') + && candidate.category !== 'meta' && !(candidate.name.includes ('タグ希望')) && !(candidate.name.includes ('bot操作'))) }) }) - ;([ + void ([ { answer: 'yes' as const, threshold: .9, @@ -2141,7 +2127,7 @@ const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] { answer: 'no' as const, threshold: .25, - text: '少し違ふ印象もある?' }]).forEach ((item, index) => { + text: '少し違う印象もある?' }]).forEach ((item, index) => { addQuestion ({ id: `winning-run:post-similarity:${ targetPost.id }:${ item.answer }:${ item.threshold }`, text: item.text, @@ -2342,8 +2328,10 @@ const chooseFallbackQuestion = ({ const shouldEnterGuessPhase = ( reason: GuessReason | null, -): reason is 'hard_max_questions' | 'winning_run_finished' => - reason === 'hard_max_questions' || reason === 'winning_run_finished' +): 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 = ( @@ -2364,65 +2352,92 @@ const winningRunQuestionCount = ( .length -const nextQuestionPlanFor = ({ - posts, - eligiblePosts, - availablePosts, - acceptedQuestions, - scores, - answers, - askedIds, - gameSeed, - recentFirstQuestionPenaltyById, - userPriorWeights, - performanceMode, - materialIndex, - matchIndex, - lastGuessQuestionCount, - winningRunTargetId, - winningRunStartAnswerCount, -}: { - posts: Post[] - eligiblePosts: Post[] - availablePosts: Post[] - acceptedQuestions: GekanatorQuestion[] - scores: Map - answers: GekanatorAnswerLog[] - askedIds: Set - gameSeed: string - recentFirstQuestionPenaltyById: Map - userPriorWeights: Map - performanceMode: GekanatorPerformanceMode - materialIndex: GekanatorQuestionMaterialIndex - matchIndex: GekanatorMatchIndex - lastGuessQuestionCount: number - winningRunTargetId: number | null - winningRunStartAnswerCount: number | null -}): { - question: GekanatorQuestion | null - guess: Post | null - guessReason: GuessReason | null - questionMode: QuestionMode - winningRunTargetId: number | null - winningRunStartAnswerCount: number | null -} => { +const nextQuestionPlanFor = ( + { posts, + eligiblePosts, + availablePosts, + acceptedQuestions, + scores, + answers, + askedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex, + lastGuessQuestionCount, + winningRunTargetId, + winningRunStartAnswerCount }: { posts: Post[] + eligiblePosts: Post[] + availablePosts: Post[] + acceptedQuestions: GekanatorQuestion[] + scores: Map + answers: GekanatorAnswerLog[] + askedIds: Set + gameSeed: string + recentFirstQuestionPenaltyById: Map + userPriorWeights: Map + performanceMode: GekanatorPerformanceMode + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + lastGuessQuestionCount: number + winningRunTargetId: number | null + winningRunStartAnswerCount: number | null }, +): { question: GekanatorQuestion | null + guess: Post | null + guessReason: GuessReason | null + questionMode: QuestionMode + winningRunTargetId: number | null + winningRunStartAnswerCount: number | null } => { + const guessablePosts = + eligiblePosts.length > 0 + ? eligiblePosts + : availablePosts + + const checkpointGuess = + answers.length > 0 + && answers.length - lastGuessQuestionCount >= minQuestionsBeforeCertainGuess + + if (answers.length >= hardMaxQuestions) + { + return { + question: null, + guess: bestPost (guessablePosts, scores), + guessReason: 'hard_max_questions', + questionMode: null, + winningRunTargetId, + winningRunStartAnswerCount } + } + + if (checkpointGuess) + { + return { + question: null, + guess: bestPost (guessablePosts, scores), + guessReason: 'question_count_checkpoint', + questionMode: null, + winningRunTargetId, + winningRunStartAnswerCount } + } + const nextQuestionsSinceLastGuess = answers.length - lastGuessQuestionCount const nextWinningRunTargetId = eligiblePosts.length === 1 - ? eligiblePosts[0]?.id ?? null - : null + ? eligiblePosts[0]?.id ?? null + : null const nextWinningRunStartAnswerCount = nextWinningRunTargetId === null - ? null - : isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + ? null + : ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) && winningRunTargetId === nextWinningRunTargetId - && winningRunStartAnswerCount !== null - ? winningRunStartAnswerCount - : answers.length + && winningRunStartAnswerCount !== null) + ? winningRunStartAnswerCount + : answers.length) const nextWinningRunTargetPost = nextWinningRunTargetId === null - ? null - : posts.find (post => post.id === nextWinningRunTargetId) ?? null + ? null + : posts.find (post => post.id === nextWinningRunTargetId) ?? null const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] => buildQuestionsForCandidateIds ({ candidateIds: scopePosts.map (post => post.id), @@ -2490,8 +2505,9 @@ const nextQuestionPlanFor = ({ const evaluationPosts = nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess - ? eligiblePosts - : availablePosts + ? eligiblePosts + : availablePosts + const evaluationQuestions = buildQuestionsForPosts (evaluationPosts) const normalQuestion = chooseQuestion ({ posts: evaluationPosts, @@ -2505,6 +2521,7 @@ const nextQuestionPlanFor = ({ performanceMode, materialIndex, matchIndex }) + const fallbackQuestion = normalQuestion ?? chooseFallbackQuestion ({ posts: evaluationPosts.length > 0 ? evaluationPosts : availablePosts, allPosts: posts, @@ -2514,19 +2531,17 @@ const nextQuestionPlanFor = ({ scores, materialIndex, matchIndex }) - if (fallbackQuestion) - return { - question: fallbackQuestion, - guess: null, - guessReason: null, - questionMode: 'normal', - winningRunTargetId: nextWinningRunTargetId, - winningRunStartAnswerCount: nextWinningRunStartAnswerCount } - const guessablePosts = - eligiblePosts.length > 0 - ? eligiblePosts - : availablePosts + if (fallbackQuestion) + { + return { + question: fallbackQuestion, + guess: null, + guessReason: null, + questionMode: 'normal', + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + } return { question: null, @@ -4184,8 +4199,8 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const dialogue = phase === 'learned' && resultWon - ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! - : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ + ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! + : <>私は洗澡鹿シーザオグカ。質問から投稿を何でも当ててみせるよ。 const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = !(introLoading) @@ -4274,7 +4289,6 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { return ( - {`グカネータ | ${ SITE_TITLE }`} @@ -4340,15 +4354,16 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
-

- {dialogue} -

+ {phase === 'intro' && ( +

+ {dialogue} +

)} {introLoading && (

{phase === 'intro' - ? '投稿を読み込んでゐます...' - : '前回のグカネータ状態を復元してゐます...'} + ? '投稿を読み込んでいます……' + : '前回のグカネータ状態を復元しています……'}

)} {(Boolean (error) || Boolean (acceptedQuestionsError)) &&

グカネータの質問データを読み込めませんでした.

} @@ -4363,6 +4378,18 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { {phase === 'intro' && readyToStart && restorePromptVisible && (
+
)} {phase === 'intro' && readyToStart && !(restorePromptVisible) && ( @@ -4389,7 +4408,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { setRestorePromptVisible (false) setPhase ('question') }}> - はじめる + 始める )} {phase === 'question' && currentQuestion && ( @@ -4490,7 +4509,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
)} {phase === 'guess' && displayedGuess && (
-

これを想像してゐたね?

+

これを想像していたね?

{isAdmin && (
@@ -4524,7 +4543,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900" onClick={rejectGuess}> - 違ふ + 違う {history.length > 0 && (