From 159ad5ed5af030474f5a1fdb08056623bef0ed49 Mon Sep 17 00:00:00 2001
From: miteruzo
Date: Tue, 9 Jun 2026 23:36:24 +0900
Subject: [PATCH] #41
---
backend/app/models/gekanator_question.rb | 5 +-
frontend/src/pages/GekanatorPage.tsx | 175 ++++++++++++++++++++++-
2 files changed, 172 insertions(+), 8 deletions(-)
diff --git a/backend/app/models/gekanator_question.rb b/backend/app/models/gekanator_question.rb
index a4c838f..8f9b925 100644
--- a/backend/app/models/gekanator_question.rb
+++ b/backend/app/models/gekanator_question.rb
@@ -13,7 +13,10 @@ class GekanatorQuestion < ApplicationRecord
validates :condition, presence: true
validates :priority_weight,
presence: true,
- numericality: { greater_than: 0 }
+ numericality: {
+ greater_than: 0,
+ less_than_or_equal_to: 3
+ }
scope :accepted, -> { where(status: 'accepted') }
end
diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx
index 7a42884..2eef5b6 100644
--- a/frontend/src/pages/GekanatorPage.tsx
+++ b/frontend/src/pages/GekanatorPage.tsx
@@ -125,7 +125,7 @@ const sourcePriorityOffset = (question: GekanatorQuestion): number => {
const priorityWeightOffset = (question: GekanatorQuestion): number =>
- (question.priorityWeight - 1) * -.8
+ (Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8
const createGameSeed = (): string => {
@@ -447,16 +447,162 @@ const softenNextQuestionIds = ({
}
+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 => {
+ 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-length-greater-than':
+ return String (condition.length)
+ case 'title-has-ascii':
+ return ''
+ }
+ }
+
+ return valueKeyFor (left) === valueKeyFor (right)
+}
+
+
+const monthForCondition = (
+ condition: GekanatorQuestion['condition'],
+): number | null => {
+ if (condition.type === 'original-month')
+ return condition.month
+
+ if (condition.type !== 'original-month-day')
+ return null
+
+ const month = Number (condition.monthDay.split ('-')[0])
+ return Number.isInteger (month) ? month : null
+}
+
+
+const isMonthCrossMatch = (
+ candidate: GekanatorQuestion['condition'],
+ previous: GekanatorQuestion['condition'],
+): boolean => {
+ const candidateMonth = monthForCondition (candidate)
+ const previousMonth = monthForCondition (previous)
+ if (candidateMonth === null || previousMonth === null)
+ return false
+
+ const sameType = candidate.type === previous.type
+ if (sameType)
+ return false
+
+ return candidateMonth === previousMonth
+}
+
+
+const isExclusiveContradiction = (
+ candidate: GekanatorQuestion['condition'],
+ previous: GekanatorQuestion['condition'],
+): boolean => {
+ const candidateGroup = exclusiveConditionGroupFor (candidate)
+ const previousGroup = exclusiveConditionGroupFor (previous)
+
+ if (candidateGroup !== null && candidateGroup === previousGroup)
+ return !(sameConditionValue (candidate, previous))
+
+ const candidateMonth = monthForCondition (candidate)
+ const previousMonth = monthForCondition (previous)
+ if (candidateMonth !== null && previousMonth !== null)
+ return candidateMonth !== previousMonth
+
+ return false
+}
+
+
+const contradictionPenaltyFor = ({
+ question,
+ answers,
+}: {
+ question: GekanatorQuestion
+ answers: GekanatorAnswerLog[]
+}): number => {
+ return answers.reduce ((sum, answer) => {
+ const previous = answer.questionCondition
+ if (!(previous))
+ return sum
+
+ switch (answer.answer)
+ {
+ case 'yes':
+ return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0)
+ case 'partial':
+ return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
+ case 'no':
+ return sum + (
+ sameConditionValue (question.condition, previous)
+ || isMonthCrossMatch (question.condition, previous)
+ ? 40
+ : 0)
+ case 'probably_no':
+ return sum + (
+ sameConditionValue (question.condition, previous)
+ || isMonthCrossMatch (question.condition, previous)
+ ? 20
+ : 0)
+ default:
+ return sum
+ }
+ }, 0)
+}
+
+
const chooseQuestion = ({
posts,
questions,
scores,
+ answers,
askedIds,
gameSeed,
}: {
posts: Post[]
questions: GekanatorQuestion[]
scores: Map
+ answers: GekanatorAnswerLog[]
askedIds: Set
gameSeed: string
}): GekanatorQuestion | null => {
@@ -527,6 +673,7 @@ const chooseQuestion = ({
const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
+ const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
const sourceBonus = sourcePriorityOffset (question)
const priorityBonus = priorityWeightOffset (question)
@@ -535,6 +682,7 @@ const chooseQuestion = ({
+ unweightedSplitScore * 8
+ tagPenalty
+ narrowPenalty
+ + contradictionPenalty
+ sourceBonus
+ priorityBonus,
narrow: narrowPenalty > 0 }
@@ -666,11 +814,18 @@ const GekanatorPage: FC = () => {
const { data: posts = [], isLoading, error } = useQuery ({
queryKey: gekanatorKeys.posts (),
- queryFn: fetchGekanatorPosts })
- const { data: acceptedQuestions = [], isFetched: acceptedQuestionsFetched } = useQuery ({
+ queryFn: fetchGekanatorPosts,
+ refetchOnWindowFocus: false })
+ const {
+ data: acceptedQuestions = [],
+ isFetched: acceptedQuestionsFetched,
+ isLoading: acceptedQuestionsLoading,
+ error: acceptedQuestionsError
+ } = useQuery ({
queryKey: gekanatorKeys.questions (),
queryFn: fetchGekanatorQuestions,
- select: questions => questions.map (restoreGekanatorQuestion) })
+ select: questions => questions.map (restoreGekanatorQuestion),
+ refetchOnWindowFocus: false })
useEffect (() => {
if (
@@ -793,6 +948,7 @@ const GekanatorPage: FC = () => {
posts: questionPosts,
questions: scoringQuestions,
scores,
+ answers,
askedIds,
gameSeed })
const answerPreviews = useMemo (
@@ -896,6 +1052,7 @@ const GekanatorPage: FC = () => {
posts: recoveredEligiblePosts,
questions: recoveredScoringQuestions,
scores: recoveredScores,
+ answers: nextAnswers,
askedIds: nextAskedIds,
gameSeed })))
)
@@ -1170,6 +1327,7 @@ const GekanatorPage: FC = () => {
: nonRejectedPosts,
questions: recovered.scoringQuestions,
scores: recovered.scores,
+ answers,
askedIds,
gameSeed })
@@ -1229,6 +1387,8 @@ const GekanatorPage: FC = () => {
phase === 'learned' && resultWon
? <>グカカカカwwwww 洗澡鹿は何でもお見通し!>
: <>私は洗澡鹿.質問から投稿を何でも当ててみせるよ>
+ const introLoading = isLoading || acceptedQuestionsLoading
+ const readyToStart = !(introLoading) && acceptedQuestionsFetched && posts.length > 0
return (
@@ -1258,15 +1418,16 @@ const GekanatorPage: FC = () => {
{dialogue}
- {isLoading && (
+ {introLoading && (
{phase === 'intro'
? '投稿を読み込んでゐます...'
: '前回のグカネータ状態を復元してゐます...'}
)}
- {Boolean (error) && 投稿を読み込めませんでした.
}
+ {(Boolean (error) || Boolean (acceptedQuestionsError))
+ && グカネータの質問データを読み込めませんでした.
}
- {phase === 'intro' && !(isLoading) && posts.length > 0 && (
+ {phase === 'intro' && readyToStart && (