グカネータ作成 / ウィニング・ラン修正 (#41) #366

マージ済み
みてるぞ が 22 個のコミットを feature/041 から main へマージ 2026-06-12 02:08:59 +09:00
14個のファイルの変更606行の追加41行の削除
コミット 7fe7dbd909 の変更だけを表示してゐます - すべてのコミットを表示
+87
ファイルの表示
@@ -20,4 +20,91 @@ class GekanatorGamesController < ApplicationController
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
end
end
def extra_questions
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
asked_ids = Array(game.answers).filter_map { |answer|
answer['questionId'] || answer[:questionId]
}
existing_example_ids =
GekanatorQuestionExample.where(post_id: game.correct_post_id)
.select(:gekanator_question_id)
questions =
GekanatorQuestion
.accepted
.where(kind: 'post_similarity', source: 'user_suggested')
.where.not(id: existing_example_ids)
.order(priority_weight: :desc, id: :asc)
render json: {
questions: questions.filter_map { |question|
json = extra_question_json(question)
next if asked_ids.include?(json[:id].to_s)
next if asked_ids.include?("post-similarity:#{ json[:id] }")
json
}.first(2)
}
end
def extra_question_answers
return head :not_found unless current_user&.admin?
game = GekanatorGame.find_by(id: params[:id])
return head :not_found unless game
answer_params = params.require(:answers)
if !answer_params.is_a?(Array)
return render_validation_error fields: { answers: ['配列で指定してください.'] }
end
answers = answer_params.map { |answer|
{
question_id: answer.require(:question_id),
answer: answer.require(:answer)
}
}
questions = GekanatorQuestion.where(id: answers.map { _1[:question_id] })
question_by_id = questions.index_by(&:id)
if questions.length != answers.length
return render_validation_error fields: { answers: ['質問が見つかりません.'] }
end
if questions.any? { |question| question.status != 'accepted' || question.kind != 'post_similarity' }
return render_validation_error fields: { answers: ['質問が不正です.'] }
end
ActiveRecord::Base.transaction do
answers.each do |item|
question = question_by_id[item[:question_id]]
example =
GekanatorQuestionExample.find_or_initialize_by(
gekanator_question: question,
post: game.correct_post,
user: current_user)
example.assign_attributes(
gekanator_game: game,
answer: item[:answer],
source: 'post_game_extra',
weight: 1.0)
example.save!
end
end
render json: { count: answers.length }, status: :created
end
private
def extra_question_json question
{
id: question.id,
text: question.text,
source: question.source,
priority_weight: question.priority_weight
}
end
end
+8 -1
ファイルの表示
@@ -11,7 +11,14 @@ class GekanatorQuestionSuggestionsController < ApplicationController
question_text: params.require(:question_text),
answer: params.require(:answer))
if suggestion.save
if suggestion.valid?
ActiveRecord::Base.transaction do
suggestion.save!
Gekanator::QuestionSuggestionPromoter.call(
suggestion: suggestion,
user: current_user)
end
render json: {
id: suggestion.id,
count: game.question_suggestions.count
+28 -2
ファイルの表示
@@ -2,7 +2,11 @@ class GekanatorQuestionsController < ApplicationController
def index
return head :not_found unless current_user&.admin?
questions = GekanatorQuestion.accepted.order(priority_weight: :desc, id: :asc)
questions =
GekanatorQuestion
.accepted
.includes(:gekanator_question_examples)
.order(priority_weight: :desc, id: :asc)
render json: {
questions: questions.map { |question| question_json(question) }
@@ -12,7 +16,7 @@ class GekanatorQuestionsController < ApplicationController
private
def question_json question
{
json = {
id: question_id_for(question),
text: question.text,
kind: question.kind,
@@ -20,6 +24,10 @@ class GekanatorQuestionsController < ApplicationController
source: question.source,
priority_weight: question.priority_weight
}
if question.kind == 'post_similarity'
json[:example_answers] = example_answers_json(question)
end
json
end
def question_id_for question
@@ -40,6 +48,8 @@ class GekanatorQuestionsController < ApplicationController
"title:length-greater-than:#{ condition[:length] }"
when 'title-has-ascii'
'title:ascii'
when 'post-similarity'
"post-similarity:#{ question.id }"
else
"catalog:#{ question.id }"
end
@@ -54,4 +64,20 @@ class GekanatorQuestionsController < ApplicationController
json
end
def example_answers_json question
question
.gekanator_question_examples
.group_by(&:post_id)
.transform_values { |examples| aggregate_answer(examples) }
end
def aggregate_answer examples
examples
.group_by(&:answer)
.map { |answer, grouped| [answer, grouped.sum(&:weight), grouped.max_by(&:updated_at)&.updated_at] }
.sort_by { |(_answer, weight, updated_at)| [-weight, -(updated_at&.to_f || 0)] }
.first
&.first
end
end
+3
ファイルの表示
@@ -5,6 +5,9 @@ class GekanatorGame < ApplicationRecord
has_many :question_suggestions,
class_name: 'GekanatorQuestionSuggestion',
dependent: :delete_all
has_many :question_examples,
class_name: 'GekanatorQuestionExample',
dependent: :delete_all
validates :answers, presence: true
validates :question_count, numericality: { greater_than_or_equal_to: 0 }
+3 -2
ファイルの表示
@@ -1,10 +1,11 @@
class GekanatorQuestion < ApplicationRecord
KINDS = ['tag', 'source', 'title', 'original_date'].freeze
KINDS = ['tag', 'source', 'title', 'original_date', 'post_similarity'].freeze
SOURCES = ['user_suggested', 'ai_generated', 'admin_curated'].freeze
STATUSES = ['pending', 'accepted', 'rejected'].freeze
STATUSES = ['pending', 'accepted', 'rejected', 'disabled'].freeze
belongs_to :gekanator_question_suggestion, optional: true
belongs_to :created_by, class_name: 'User', optional: true
has_many :gekanator_question_examples, dependent: :delete_all
validates :kind, presence: true, inclusion: { in: KINDS }
validates :source, presence: true, inclusion: { in: SOURCES }
+17
ファイルの表示
@@ -0,0 +1,17 @@
class GekanatorQuestionExample < ApplicationRecord
ANSWERS = GekanatorQuestionSuggestion::ANSWERS
SOURCES = ['initial_suggestion', 'post_game_extra'].freeze
belongs_to :gekanator_question
belongs_to :post
belongs_to :user
belongs_to :gekanator_game, optional: true
validates :answer, presence: true, inclusion: { in: ANSWERS }
validates :source, presence: true, inclusion: { in: SOURCES }
validates :weight,
presence: true,
numericality: {
greater_than: 0
}
end
+1
ファイルの表示
@@ -21,6 +21,7 @@ class Post < ApplicationRecord
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
class_name: 'PostImplication',
+48
ファイルの表示
@@ -0,0 +1,48 @@
module Gekanator
class QuestionSuggestionPromoter
def self.call(...) = new(...).call
def initialize suggestion:, user:
@suggestion = suggestion
@user = user
end
def call
suggestion.with_lock do
return promoted_question if suggestion.processed?
question = GekanatorQuestion.create!(
text: suggestion.question_text,
kind: 'post_similarity',
source: 'user_suggested',
status: 'accepted',
priority_weight: 1.2,
condition: {
type: 'post-similarity',
postId: suggestion.gekanator_game.correct_post_id,
answer: suggestion.answer,
threshold: 0.65
},
gekanator_question_suggestion: suggestion,
created_by: user)
GekanatorQuestionExample.create!(
gekanator_question: question,
post: suggestion.gekanator_game.correct_post,
user: user,
gekanator_game: suggestion.gekanator_game,
answer: suggestion.answer,
source: 'initial_suggestion')
suggestion.update!(processed: true)
question
end
end
private
attr_reader :suggestion, :user
def promoted_question
suggestion.gekanator_questions.order(id: :desc).first
end
end
end
+6 -1
ファイルの表示
@@ -64,7 +64,12 @@ Rails.application.routes.draw do
end
namespace :gekanator do
resources :games, only: [:create], controller: '/gekanator_games'
resources :games, only: [:create], controller: '/gekanator_games' do
member do
get :extra_questions
post :extra_question_answers
end
end
resources :posts, only: [:index], controller: '/gekanator_posts'
resources :questions, only: [:index], controller: '/gekanator_questions'
resources :question_suggestions,
+19
ファイルの表示
@@ -0,0 +1,19 @@
class CreateGekanatorQuestionExamples < ActiveRecord::Migration[8.0]
def change
create_table :gekanator_question_examples do |t|
t.references :gekanator_question, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :gekanator_game, null: true, foreign_key: true
t.string :answer, null: false
t.string :source, null: false, default: 'post_game_extra'
t.float :weight, null: false, default: 1.0
t.timestamps
end
add_index :gekanator_question_examples,
[:gekanator_question_id, :post_id, :user_id],
unique: true,
name: 'idx_gekanator_question_examples_on_question_post_user'
end
end
生成ファイル
+22 -1
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
ActiveRecord::Schema[8.0].define(version: 2026_06_10_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -75,6 +75,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "gekanator_question_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.bigint "gekanator_game_id"
t.string "answer", null: false
t.string "source", default: "post_game_extra", null: false
t.float "weight", default: 1.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["gekanator_game_id"], name: "index_gekanator_question_examples_on_gekanator_game_id"
t.index ["gekanator_question_id", "post_id", "user_id"], name: "idx_gekanator_question_examples_on_question_post_user", unique: true
t.index ["gekanator_question_id"], name: "index_gekanator_question_examples_on_gekanator_question_id"
t.index ["post_id"], name: "index_gekanator_question_examples_on_post_id"
t.index ["user_id"], name: "index_gekanator_question_examples_on_user_id"
end
create_table "gekanator_question_suggestions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "gekanator_game_id", null: false
t.bigint "user_id", null: false
@@ -553,6 +570,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_09_001000) do
add_foreign_key "gekanator_games", "posts", column: "correct_post_id"
add_foreign_key "gekanator_games", "posts", column: "guessed_post_id"
add_foreign_key "gekanator_games", "users"
add_foreign_key "gekanator_question_examples", "gekanator_games"
add_foreign_key "gekanator_question_examples", "gekanator_questions"
add_foreign_key "gekanator_question_examples", "posts"
add_foreign_key "gekanator_question_examples", "users"
add_foreign_key "gekanator_question_suggestions", "gekanator_games", on_delete: :cascade
add_foreign_key "gekanator_question_suggestions", "users"
add_foreign_key "gekanator_questions", "gekanator_question_suggestions"
+99 -11
ファイルの表示
@@ -21,6 +21,7 @@ export type GekanatorQuestionKind =
| 'source'
| 'title'
| 'original_date'
| 'post_similarity'
export type GekanatorQuestionSource =
| 'default'
@@ -36,6 +37,18 @@ export type GekanatorQuestionCondition =
| { type: 'original-month-day'; monthDay: string }
| { type: 'title-length-greater-than'; length: number }
| { type: 'title-has-ascii' }
| {
type: 'post-similarity'
postId: number
answer: GekanatorAnswerValue
threshold: number
}
export type GekanatorExtraQuestion = {
id: number
text: string
source: GekanatorQuestionSource
priorityWeight: number }
export type StoredGekanatorQuestion = {
id: string
@@ -43,7 +56,8 @@ export type StoredGekanatorQuestion = {
kind: GekanatorQuestionKind
condition: GekanatorQuestionCondition
source?: GekanatorQuestionSource
priorityWeight?: number }
priorityWeight?: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> }
export type GekanatorQuestion = {
id: string
@@ -52,8 +66,24 @@ export type GekanatorQuestion = {
condition: GekanatorQuestionCondition
source: GekanatorQuestionSource
priorityWeight: number
exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue>
test: (post: Post) => boolean }
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
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 countBy = <T extends string | number> (values: T[]): Map<T, number> => {
const counts = new Map<T, number> ()
values.forEach (value => counts.set (value, (counts.get (value) ?? 0) + 1))
@@ -172,24 +202,59 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = (
post: Post,
condition: GekanatorQuestionCondition,
question: StoredGekanatorQuestion,
): boolean => {
switch (condition.type)
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.condition.type === 'post-similarity'
? directAnswer === question.condition.answer
: directAnswer === 'yes'
switch (question.condition.type)
{
case 'tag':
return questionableTag (post, condition.key)
return questionableTag (post, question.condition.key)
case 'source':
return hostOf (post) === condition.host
return hostOf (post) === question.condition.host
case 'original-year':
return originalYearOf (post) === condition.year
return originalYearOf (post) === question.condition.year
case 'original-month':
return originalMonthOf (post) === condition.month
return originalMonthOf (post) === question.condition.month
case 'original-month-day':
return originalMonthDayOf (post) === condition.monthDay
return originalMonthDayOf (post) === question.condition.monthDay
case 'title-length-greater-than':
return (post.title?.length ?? 0) > condition.length
return (post.title?.length ?? 0) > question.condition.length
case 'title-has-ascii':
return /[A-Za-z0-9]/.test (post.title ?? '')
case 'post-similarity':
return false
}
}
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return directAnswer
switch (question.condition.type)
{
case 'tag':
case 'source':
case 'original-year':
case 'original-month':
case 'original-month-day':
case 'title-length-greater-than':
case 'title-has-ascii':
return questionMatches (post, question) ? 'yes' : 'no'
case 'post-similarity':
return null
}
}
@@ -200,7 +265,7 @@ export const restoreGekanatorQuestion = (
...question,
source: question.source ?? 'default',
priorityWeight: question.priorityWeight ?? 1,
test: (post: Post) => questionMatches (post, question.condition) })
test: (post: Post) => questionMatches (post, question) })
export const storeGekanatorQuestion = (
@@ -211,7 +276,8 @@ export const storeGekanatorQuestion = (
kind: question.kind,
condition: question.condition,
source: question.source,
priorityWeight: question.priorityWeight })
priorityWeight: question.priorityWeight,
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
@@ -226,6 +292,15 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`)
return data.questions
}
export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => {
const tagCounts = countBy (posts.flatMap (post =>
post.tags
@@ -393,3 +468,16 @@ export const saveGekanatorQuestionSuggestion = async ({
gekanator_game_id: gekanatorGameId,
question_text: questionText,
answer })
export const saveGekanatorExtraQuestionAnswers = async ({
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
+3 -1
ファイルの表示
@@ -11,7 +11,9 @@ export const postsKeys = {
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const }
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number) =>
['gekanator', 'games', gameId, 'extra-questions'] as const }
export const tagsKeys = {
root: ['tags'] as const,
+262 -22
ファイルの表示
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
@@ -6,9 +6,12 @@ import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { buildGekanatorQuestions,
expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
fetchGekanatorPosts,
restoreGekanatorQuestion,
saveGekanatorExtraQuestionAnswers,
saveGekanatorGame,
saveGekanatorQuestionSuggestion,
storeGekanatorQuestion } from '@/lib/gekanator'
@@ -19,6 +22,7 @@ import type { FC } from 'react'
import type { GekanatorAnswerLog,
GekanatorAnswerValue,
GekanatorExtraQuestion,
GekanatorQuestion,
StoredGekanatorQuestion } from '@/lib/gekanator'
import type { Post } from '@/types'
@@ -31,6 +35,7 @@ type Phase =
| 'end'
| 'review'
| 'question_suggestion'
| 'extra_questions'
| 'learned'
type AnswerOption = {
@@ -87,7 +92,10 @@ type StoredGekanatorGame = {
gameSeed?: string
questionSuggestion: string
questionSuggestionAnswer: GekanatorAnswerValue
questionSuggestionCount?: number }
questionSuggestionCount?: number
extraQuestions?: GekanatorExtraQuestion[]
extraQuestionAnswers?: Record<string, GekanatorAnswerValue>
extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' }
const answerOptions: AnswerOption[] = [
{ label: 'はい', value: 'yes' },
@@ -219,6 +227,16 @@ const loadStoredGame = (): StoredGekanatorGame | null => {
const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'
const resettableExtraQuestionState = (): {
extraQuestions: GekanatorExtraQuestion[]
extraQuestionAnswers: Record<string, GekanatorAnswerValue>
extraQuestionState: 'idle'
} => ({
extraQuestions: [],
extraQuestionAnswers: { },
extraQuestionState: 'idle' })
const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
switch (answer)
{
@@ -236,6 +254,55 @@ const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
}
const answerScalarFor = (
answer: GekanatorAnswerValue | null,
): number | null => {
switch (answer)
{
case 'yes':
return 1
case 'partial':
return .5
case 'probably_no':
return -.5
case 'no':
return -1
case 'unknown':
case null:
return null
}
}
const deltaForExpectedAnswer = (
expected: GekanatorAnswerValue | null,
answer: GekanatorAnswerValue,
): number => {
if (answer === 'unknown' || expected === null || expected === 'unknown')
return 0
if (expected === 'yes' || expected === 'no')
return deltaFor (expected === 'yes', answer)
const expectedScalar = answerScalarFor (expected)
const answerScalar = answerScalarFor (answer)
if (expectedScalar === null || answerScalar === null)
return 0
const distance = Math.abs (expectedScalar - answerScalar)
if (distance >= 2)
return -4
if (distance >= 1.5)
return -2
if (distance >= 1)
return 0
if (distance >= .5)
return 2
return 4
}
const answerWeightFor = (
questionId: string,
softenedQuestionIds: Set<string>,
@@ -277,10 +344,11 @@ const recalculateScores = ({
const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
posts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0)
+ deltaFor (question.test (post), answer.answer) * weight)
+ deltaForExpectedAnswer (expected, answer.answer) * weight)
})
})
@@ -318,9 +386,10 @@ const candidatePostsFor = ({
switch (answer.answer)
{
case 'yes':
return question.test (post)
case 'no':
return !(question.test (post))
case 'no': {
const expected = expectedAnswerForQuestion (question, post)
return expected === null || expected === 'unknown' || expected === answer.answer
}
default:
return true
}
@@ -378,20 +447,19 @@ const previewAnswer = ({
answer: GekanatorAnswerValue
}): AnswerPreview => {
const hardFilteredPosts =
answer === 'yes'
? posts.filter (post => question.test (post))
: answer === 'no'
? posts.filter (post => !(question.test (post)))
: posts
answer === 'unknown'
? posts
: posts.filter (post => expectedAnswerForQuestion (question, post) === answer)
const nextPosts =
(answer === 'yes' || answer === 'no') && hardFilteredPosts.length > 0
answer !== 'unknown' && hardFilteredPosts.length > 0
? hardFilteredPosts
: posts
const nextScores = new Map (scores)
nextPosts.forEach (post => {
const expected = expectedAnswerForQuestion (question, post)
nextScores.set (
post.id,
(nextScores.get (post.id) ?? 0) + deltaFor (question.test (post), answer))
(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
})
const confidences = confidencesFor (nextPosts, nextScores)
@@ -497,6 +565,8 @@ const sameConditionValue = (
return String (condition.length)
case 'title-has-ascii':
return ''
case 'post-similarity':
return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
}
}
@@ -757,16 +827,13 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
const expectedAnswerFor = (
question: GekanatorQuestion | undefined,
correctPost: Post | null,
): GekanatorAnswerValue | null => {
if (!(question) || !(correctPost))
return null
return question.test (correctPost) ? 'yes' : 'no'
}
): GekanatorAnswerValue | null =>
expectedAnswerForQuestion (question, correctPost)
const GekanatorPage: FC = () => {
const storedGame = useMemo (loadStoredGame, [])
const queryClient = useQueryClient ()
const [gameSeed, setGameSeed] = useState (
storedGame?.gameSeed ?? createGameSeed ())
const [phase, setPhase] = useState<Phase> (storedGame?.phase ?? 'intro')
@@ -810,6 +877,14 @@ const GekanatorPage: FC = () => {
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 ({
@@ -876,7 +951,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount }
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState }
try
{
@@ -908,7 +986,10 @@ const GekanatorPage: FC = () => {
gameSeed,
questionSuggestion,
questionSuggestionAnswer,
questionSuggestionCount])
questionSuggestionCount,
extraQuestions,
extraQuestionAnswers,
extraQuestionState])
const eligiblePosts = useMemo (
() => candidatePostsFor ({
@@ -985,10 +1066,25 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
}})
const extraQuestionAnswersMutation = useMutation ({
mutationFn: saveGekanatorExtraQuestionAnswers,
onSuccess: () => {
setExtraQuestionState ('saved')
setPhase ('learned')
}})
const resetExtraQuestionState = () => {
const next = resettableExtraQuestionState ()
setExtraQuestions (next.extraQuestions)
setExtraQuestionAnswers (next.extraQuestionAnswers)
setExtraQuestionState (next.extraQuestionState)
extraQuestionAnswersMutation.reset ()
}
const reset = () => {
clearStoredGame ()
saveMutation.reset ()
questionSuggestionMutation.reset ()
setPhase ('intro')
setScores (new Map ())
setAnswers ([])
@@ -1010,6 +1106,7 @@ const GekanatorPage: FC = () => {
setQuestionSuggestion ('')
setQuestionSuggestionAnswer ('yes')
setQuestionSuggestionCount (0)
resetExtraQuestionState ()
setHistory ([])
}
@@ -1188,6 +1285,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setReviewGuessedPostId (guessedPostId)
@@ -1203,6 +1301,7 @@ const GekanatorPage: FC = () => {
saveMutation.reset ()
questionSuggestionMutation.reset ()
resetExtraQuestionState ()
setSaved (false)
setSavedGameId (null)
setSelectingCorrectPost (false)
@@ -1236,6 +1335,7 @@ const GekanatorPage: FC = () => {
}
const saveAndLearn = () => {
resetExtraQuestionState ()
saveReviewedResult (() => setPhase ('learned'))
}
@@ -1344,6 +1444,7 @@ const GekanatorPage: FC = () => {
const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setAnswers (answers.map ((answer, i) =>
i === index ? { ...answer, answer: value } : answer))
}
@@ -1353,6 +1454,7 @@ const GekanatorPage: FC = () => {
{
setSaved (false)
setSavedGameId (null)
resetExtraQuestionState ()
setReviewCorrectPostId (post.id)
setSelectingCorrectPost (false)
setSearch ('')
@@ -1383,6 +1485,60 @@ const GekanatorPage: FC = () => {
})
.slice (0, 20)
const loadExtraQuestions = async (gameId: number) => {
extraQuestionAnswersMutation.reset ()
setExtraQuestionState ('loading')
setExtraQuestions ([])
setExtraQuestionAnswers ({ })
setPhase ('extra_questions')
try
{
const questions = await queryClient.fetchQuery ({
queryKey: gekanatorKeys.extraQuestions (gameId),
queryFn: () => fetchGekanatorExtraQuestions (gameId) })
setExtraQuestions (questions)
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 saveExtraQuestions = () => {
if (
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 dialogue =
phase === 'learned' && resultWon
? <>wwwww <ruby>鹿<rt></rt></ruby>!</>
@@ -1650,6 +1806,18 @@ const GekanatorPage: FC = () => {
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={reviewCorrectPostId === null
|| saveMutation.isPending
|| extraQuestionState === 'loading'
|| extraQuestionAnswersMutation.isPending}
onClick={startExtraQuestions}>
</button>
</div>
</div>)}
@@ -1843,9 +2011,81 @@ const GekanatorPage: FC = () => {
</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"> 2 </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 => (
<button
key={option.value}
type="button"
className={cn (
'rounded border px-3 py-2',
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')}
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={
extraQuestionState !== 'ready'
|| extraQuestionAnswersMutation.isPending
|| extraQuestions.some (
question => !(extraQuestionAnswers[String (question.id)]))
}
onClick={saveExtraQuestions}>
</button>
</div>
</div>)}
{phase === 'learned' && (
<div className="space-y-3">
<p></p>
<p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}</p>
<button
type="button"
className="rounded bg-pink-600 px-4 py-2 font-bold text-white