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/backend/app/controllers/gekanator_games_controller.rb b/backend/app/controllers/gekanator_games_controller.rb index 628f40d..48f9624 100644 --- a/backend/app/controllers/gekanator_games_controller.rb +++ b/backend/app/controllers/gekanator_games_controller.rb @@ -1,6 +1,6 @@ class GekanatorGamesController < ApplicationController def create - return head :not_found unless current_user&.admin? + return head :unauthorized unless current_user guessed_post_id = params.require(:guessed_post_id) correct_post_id = params[:correct_post_id].presence @@ -22,10 +22,8 @@ class GekanatorGamesController < ApplicationController 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 + game = find_owned_game + return if performed? questions = GekanatorQuestion @@ -45,10 +43,8 @@ class GekanatorGamesController < ApplicationController 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 + game = find_owned_game + return if performed? answer_params = params.require(:answers) if !answer_params.is_a?(Array) @@ -137,4 +133,16 @@ class GekanatorGamesController < ApplicationController question.priority_weight.to_f / (1.0 + sample_count * 0.15) end + + def find_owned_game + return head :unauthorized unless current_user + + game = GekanatorGame.find_by(id: params[:id]) + return head :not_found unless game + if !current_user.admin? && game.user_id != current_user.id + return head :not_found + end + + game + end end diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb index 9654920..235b1c9 100644 --- a/backend/app/controllers/gekanator_posts_controller.rb +++ b/backend/app/controllers/gekanator_posts_controller.rb @@ -1,7 +1,5 @@ class GekanatorPostsController < ApplicationController def index - return head :not_found unless current_user&.admin? - posts = Post .preload(tags: :tag_name) diff --git a/backend/app/controllers/gekanator_question_suggestions_controller.rb b/backend/app/controllers/gekanator_question_suggestions_controller.rb index d3f83aa..8f6c943 100644 --- a/backend/app/controllers/gekanator_question_suggestions_controller.rb +++ b/backend/app/controllers/gekanator_question_suggestions_controller.rb @@ -1,9 +1,12 @@ class GekanatorQuestionSuggestionsController < ApplicationController def create - return head :not_found unless current_user&.admin? + return head :unauthorized unless current_user game = GekanatorGame.find_by(id: params.require(:gekanator_game_id)) return head :not_found unless game + if !current_user.admin? && game.user_id != current_user.id + return head :not_found + end suggestion = GekanatorQuestionSuggestion.new( gekanator_game: game, diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index b586aa2..159bf19 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -1,7 +1,5 @@ class GekanatorQuestionsController < ApplicationController def index - return head :not_found unless current_user&.admin? - questions = GekanatorQuestion .accepted @@ -49,6 +47,8 @@ class GekanatorQuestionsController < ApplicationController "title:length-at-least:#{ condition[:length].to_i + 1 }" when 'title-has-ascii' 'title:ascii' + when 'title-contains' + "title:contains:#{ condition[:text] }" when 'post-similarity' "post-similarity:#{ question.id }" else @@ -77,6 +77,8 @@ class GekanatorQuestionsController < ApplicationController case condition[:type] when 'title-length-at-least' "タイトルは #{ condition[:length] } 文字以上?" + when 'title-contains' + "題名に「#{ condition[:text] }」が含まれる?" else question.text end diff --git a/backend/app/services/gekanator/question_suggestion_ai_converter.rb b/backend/app/services/gekanator/question_suggestion_ai_converter.rb index 629732c..5af2caf 100644 --- a/backend/app/services/gekanator/question_suggestion_ai_converter.rb +++ b/backend/app/services/gekanator/question_suggestion_ai_converter.rb @@ -1,5 +1,15 @@ module Gekanator class QuestionSuggestionAiConverter + # Temporary heuristic converter for #361. + # This creates pending ai_generated questions without external LLM calls; + # accepted questions are still distributed only after admin approval. + TITLE_LENGTH_RE = /\Aタイトルは\s*(\d+)\s*文字以上[??]\z/ + ORIGINAL_YEAR_RE = /\Aオリジナルの投稿年は\s*(\d{4})\s*年[??]\z/ + ORIGINAL_MONTH_RE = /\Aオリジナルの投稿月は\s*(\d{1,2})\s*月[??]\z/ + ORIGINAL_MONTH_DAY_RE = /\Aオリジナルの投稿日は\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日[??]\z/ + TITLE_CONTAINS_RE = /\A題名に「(.+?)」が含まれる[??]\z/ + SOURCE_RE = /\A(.+?)\s+の投稿を思[ひい]浮かべて[ゐい]る[??]\z/ + def self.call(...) = new(...).call def initialize suggestion:, user: @@ -8,11 +18,151 @@ module Gekanator end def call - raise NotImplementedError, 'AI question conversion is not implemented yet.' + suggestion.with_lock do + existing = existing_generated_question + return existing if existing + + run = suggestion.gekanator_ai_runs.create!( + model: 'heuristic_converter_v1', + status: 'running', + input_tokens: 0, + output_tokens: 0, + estimated_cost_jpy: 0) + + question_attributes = build_question + question = + question_attributes && + GekanatorQuestion.create!( + **question_attributes, + source: 'ai_generated', + status: 'pending', + gekanator_question_suggestion: suggestion, + created_by: user) + + run.update!(status: question ? 'succeeded' : 'failed') + question + end + rescue => error + run&.update!(status: 'failed') if run&.persisted? && run.status != 'failed' + raise error end private attr_reader :suggestion, :user + + def existing_generated_question + suggestion + .gekanator_questions + .where(source: 'ai_generated') + .order(id: :desc) + .first + end + + def build_question + text = normalized_text + return nil if text.blank? + + structured_question_for(text) || post_similarity_question_for(text) + end + + def normalized_text + suggestion.question_text.to_s.gsub(/[[:space:]]+/, ' ').strip + end + + def structured_question_for text + case text + when TITLE_LENGTH_RE + length = Regexp.last_match(1).to_i + return nil if length <= 0 + + { + text:, + kind: 'title', + condition: { + type: 'title-length-at-least', + length: + }, + priority_weight: 0.95 + } + when /\A題名に英数字が混じって[ゐい]る[??]\z/ + { + text: '題名に英数字が混じってゐる?', + kind: 'title', + condition: { type: 'title-has-ascii' }, + priority_weight: 0.95 + } + when ORIGINAL_YEAR_RE + year = Regexp.last_match(1).to_i + { + text:, + kind: 'original_date', + condition: { type: 'original-year', year: }, + priority_weight: 0.95 + } + when ORIGINAL_MONTH_RE + month = Regexp.last_match(1).to_i + return nil unless month.between?(1, 12) + + { + text:, + kind: 'original_date', + condition: { type: 'original-month', month: }, + priority_weight: 0.95 + } + when ORIGINAL_MONTH_DAY_RE + month = Regexp.last_match(1).to_i + day = Regexp.last_match(2).to_i + return nil unless month.between?(1, 12) && day.between?(1, 31) + + { + text:, + kind: 'original_date', + condition: { + type: 'original-month-day', + monthDay: "#{ month }-#{ day }" + }, + priority_weight: 0.95 + } + when TITLE_CONTAINS_RE + title_text = Regexp.last_match(1).to_s.strip + return nil if title_text.blank? + + { + text: "題名に「#{ title_text }」が含まれる?", + kind: 'title', + condition: { type: 'title-contains', text: title_text }, + priority_weight: 0.95 + } + when SOURCE_RE + host = Regexp.last_match(1).to_s.strip + return nil if host.blank? + + { + text:, + kind: 'source', + condition: { type: 'source', host: }, + priority_weight: 0.95 + } + else + nil + end + end + + def post_similarity_question_for text + return nil if suggestion.answer == 'unknown' + + { + text:, + kind: 'post_similarity', + condition: { + type: 'post-similarity', + postId: suggestion.gekanator_game.correct_post_id, + answer: suggestion.answer, + threshold: 0.65 + }, + priority_weight: 1.0 + } + end end end diff --git a/backend/spec/requests/gekanator_games_spec.rb b/backend/spec/requests/gekanator_games_spec.rb index 79c0f5a..236f23e 100644 --- a/backend/spec/requests/gekanator_games_spec.rb +++ b/backend/spec/requests/gekanator_games_spec.rb @@ -52,16 +52,16 @@ RSpec.describe 'Gekanator games API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found without an admin user' do + it 'returns unauthorized without a user' do post '/gekanator/games', params: { guessed_post_id: guessed_post.id, correct_post_id: guessed_post.id, answers: [] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:unauthorized) end - it 'returns not found for a non-admin user' do + it 'stores a game for a non-admin user' do sign_in_as user post '/gekanator/games', params: { @@ -69,7 +69,8 @@ RSpec.describe 'Gekanator games API', type: :request do correct_post_id: guessed_post.id, answers: [{ question_id: 'tag:1', answer: 'yes' }] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).user).to eq(user) end end end diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 16c5244..7ae3f1f 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -129,7 +129,7 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found for a non-admin user' do + it 'stores a game result for a non-admin user' do sign_in_as member post '/gekanator/games', params: { @@ -138,7 +138,18 @@ RSpec.describe 'Gekanator learning API', type: :request do answers: [] } - expect(response).to have_http_status(:not_found) + expect(response).to have_http_status(:created) + expect(GekanatorGame.find(json['id']).user).to eq(member) + end + + it 'returns unauthorized without a user' do + post '/gekanator/games', params: { + guessed_post_id: guessed_post.id, + correct_post_id: correct_post.id, + answers: [] + } + + expect(response).to have_http_status(:unauthorized) end end @@ -261,17 +272,57 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end - it 'returns not found for a non-admin user' do + it 'allows a non-admin user to suggest a question for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) sign_in_as member - post '/gekanator/question_suggestions', params: { - gekanator_game_id: game.id, - question_text: 'member question?', - answer: 'yes' - } + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: member_game.id, + question_text: 'member question?', + answer: 'yes' + } + }.to change { GekanatorQuestionSuggestion.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionSuggestion.last).to have_attributes( + gekanator_game_id: member_game.id, + user_id: member.id + ) + end + + it 'returns not found for another user game' do + sign_in_as member + + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } expect(response).to have_http_status(:not_found) end + + it 'returns unauthorized without a user' do + expect { + post '/gekanator/question_suggestions', params: { + gekanator_game_id: game.id, + question_text: 'member question?', + answer: 'yes' + } + }.not_to change { GekanatorQuestionSuggestion.count } + + expect(response).to have_http_status(:unauthorized) + end end describe 'GET /gekanator/games/:id/extra_questions' do @@ -377,6 +428,38 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:ok) expect(json['questions'].map { _1['id'] }).to contain_exactly(accepted.id) end + + it 'allows a non-admin user to fetch extra questions for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + accepted = create_post_similarity_question!(text: 'accepted?') + sign_in_as member + + get "/gekanator/games/#{member_game.id}/extra_questions" + + expect(response).to have_http_status(:ok) + expect(json['questions'].map { _1['id'] }).to include(accepted.id) + end + + it 'returns not found for another user game' do + sign_in_as member + + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:not_found) + end + + it 'returns unauthorized without a user' do + get "/gekanator/games/#{game.id}/extra_questions" + + expect(response).to have_http_status(:unauthorized) + end end describe 'POST /gekanator/games/:id/extra_question_answers' do @@ -503,6 +586,69 @@ RSpec.describe 'Gekanator learning API', type: :request do expect(response).to have_http_status(:unprocessable_entity) end + + it 'allows a non-admin user to answer extra questions for their own game' do + member_game = GekanatorGame.create!( + user: member, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + question = create_post_similarity_question!(text: 'extra?') + sign_in_as member + + expect { + post "/gekanator/games/#{member_game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + }.to change { GekanatorQuestionExample.count }.by(1) + + expect(response).to have_http_status(:created) + expect(GekanatorQuestionExample.last).to have_attributes( + user_id: member.id, + gekanator_game_id: member_game.id + ) + end + + it 'returns not found for another user game' do + question = create_post_similarity_question!(text: 'extra?') + sign_in_as member + + expect { + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + }.not_to change { GekanatorQuestionExample.count } + + expect(response).to have_http_status(:not_found) + end + + it 'returns unauthorized without a user' do + question = create_post_similarity_question!(text: 'extra?') + + post "/gekanator/games/#{game.id}/extra_question_answers", params: { + answers: [ + { + question_id: question.id, + answer: 'yes' + } + ] + } + + expect(response).to have_http_status(:unauthorized) + end end describe 'GET /gekanator/questions' do @@ -608,5 +754,33 @@ RSpec.describe 'Gekanator learning API', type: :request do 'length' => 21 ) end + + it 'returns title-contains questions without authentication' do + GekanatorQuestion.create!( + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + source: 'ai_generated', + status: 'accepted', + priority_weight: 0.95, + condition: { + type: 'title-contains', + text: '結束バンド' + }, + created_by: admin + ) + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_json = json['questions'].find { _1['id'] == 'title:contains:結束バンド' } + expect(question_json).to include( + 'text' => '題名に「結束バンド」が含まれる?', + 'kind' => 'title' + ) + expect(question_json['condition']).to include( + 'type' => 'title-contains', + 'text' => '結束バンド' + ) + end end end diff --git a/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb b/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb new file mode 100644 index 0000000..5b9023a --- /dev/null +++ b/backend/spec/services/gekanator/question_suggestion_ai_converter_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +RSpec.describe Gekanator::QuestionSuggestionAiConverter do + let(:user) { create(:user, :member) } + let(:guessed_post) { Post.create!(title: 'guess', url: 'https://example.com/guess') } + let(:correct_post) { Post.create!(title: 'correct', url: 'https://example.com/correct') } + let(:game) do + GekanatorGame.create!( + user: user, + guessed_post: guessed_post, + correct_post: correct_post, + won: false, + question_count: 1, + answers: [{ 'question_id' => 'tag:1', 'answer' => 'yes' }] + ) + end + + def create_suggestion!(question_text:, answer: 'yes') + GekanatorQuestionSuggestion.create!( + gekanator_game: game, + user: user, + question_text: question_text, + answer: answer + ) + end + + it 'converts title-contains suggestions to pending ai-generated questions' do + suggestion = create_suggestion!(question_text: '題名に「結束バンド」が含まれる?') + + expect { + described_class.call(suggestion: suggestion, user: user) + }.to change { GekanatorQuestion.count }.by(1) + .and change { GekanatorAiRun.count }.by(1) + + question = GekanatorQuestion.last + expect(question).to have_attributes( + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + source: 'ai_generated', + status: 'pending', + priority_weight: 0.95, + gekanator_question_suggestion_id: suggestion.id, + created_by_id: user.id + ) + expect(question.condition).to include( + 'type' => 'title-contains', + 'text' => '結束バンド' + ) + expect(GekanatorAiRun.last).to have_attributes( + gekanator_question_suggestion_id: suggestion.id, + model: 'heuristic_converter_v1', + status: 'succeeded' + ) + end + + it 'converts concrete non-unknown suggestions to post-similarity questions' do + suggestion = create_suggestion!( + question_text: '喜多ちゃんが泣いてる?', + answer: 'partial' + ) + + question = described_class.call(suggestion: suggestion, user: user) + + expect(question).to have_attributes( + text: '喜多ちゃんが泣いてる?', + kind: 'post_similarity', + source: 'ai_generated', + status: 'pending', + priority_weight: 1.0 + ) + expect(question.condition).to include( + 'type' => 'post-similarity', + 'postId' => correct_post.id, + 'answer' => 'partial', + 'threshold' => 0.65 + ) + end + + it 'records a failed run when the suggestion cannot be converted' do + suggestion = create_suggestion!( + question_text: 'よく分からない質問?', + answer: 'unknown' + ) + + expect { + expect(described_class.call(suggestion: suggestion, user: user)).to be_nil + }.not_to change { GekanatorQuestion.count } + + expect(GekanatorAiRun.last).to have_attributes( + gekanator_question_suggestion_id: suggestion.id, + status: 'failed' + ) + end + + it 'returns an existing generated question without creating a duplicate run' do + suggestion = create_suggestion!(question_text: 'タイトルは 10 文字以上?') + existing = GekanatorQuestion.create!( + text: 'タイトルは 10 文字以上?', + kind: 'title', + source: 'ai_generated', + status: 'pending', + priority_weight: 0.95, + condition: { type: 'title-length-at-least', length: 10 }, + gekanator_question_suggestion: suggestion, + created_by: user + ) + + expect { + expect(described_class.call(suggestion: suggestion, user: user)).to eq(existing) + }.not_to change { GekanatorAiRun.count } + end +end diff --git a/frontend/public/gekanator/mascot-celebrate.png b/frontend/public/gekanator/mascot-celebrate.png new file mode 100644 index 0000000..6af3e9f Binary files /dev/null and b/frontend/public/gekanator/mascot-celebrate.png differ diff --git a/frontend/public/gekanator/mascot-confident.png b/frontend/public/gekanator/mascot-confident.png new file mode 100644 index 0000000..cd34892 Binary files /dev/null and b/frontend/public/gekanator/mascot-confident.png differ diff --git a/frontend/public/gekanator/mascot-failed.png b/frontend/public/gekanator/mascot-failed.png new file mode 100644 index 0000000..8ff846a Binary files /dev/null and b/frontend/public/gekanator/mascot-failed.png differ diff --git a/frontend/public/gekanator/mascot-idle.png b/frontend/public/gekanator/mascot-idle.png new file mode 100644 index 0000000..127028e Binary files /dev/null and b/frontend/public/gekanator/mascot-idle.png differ diff --git a/frontend/public/gekanator/mascot-thinking-far.png b/frontend/public/gekanator/mascot-thinking-far.png new file mode 100644 index 0000000..f7d38f6 Binary files /dev/null and b/frontend/public/gekanator/mascot-thinking-far.png differ diff --git a/frontend/public/gekanator/mascot-thinking-mid.png b/frontend/public/gekanator/mascot-thinking-mid.png new file mode 100644 index 0000000..b71afae Binary files /dev/null and b/frontend/public/gekanator/mascot-thinking-mid.png differ diff --git a/frontend/public/gekanator/mascot-thinking-near.png b/frontend/public/gekanator/mascot-thinking-near.png new file mode 100644 index 0000000..3538a0e Binary files /dev/null and b/frontend/public/gekanator/mascot-thinking-near.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6809e3a..78e7676 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,7 +40,7 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' import WikiNewPage from '@/pages/wiki/WikiNewPage' import WikiSearchPage from '@/pages/wiki/WikiSearchPage' -import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' +import type { Dispatch, FC, SetStateAction } from 'react' import type { User } from '@/types' @@ -81,10 +81,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - - - }/> + }/> }/> }/> @@ -92,16 +89,6 @@ const RouteTransitionWrapper = ({ user, setUser }: { } -const AdminOnly = ({ user, children }: { - user: User | null - children: ReactNode }) => { - if (user?.role !== 'admin') - return - - return <>{children} -} - - const PostDetailRoute = ({ user }: { user: User | null }) => { const location = useLocation () const key = location.pathname diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 4a7e20b..ed2f596 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -66,7 +66,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: 'おたのしみ', visible: false, subMenu: [ - { name: '上映会 (β)', to: '/theatres/1' }] }, + { name: '上映会 (β)', to: '/theatres/1' }, + { name: 'グカネータ (β)', to: '/gekanator' }] }, { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: '一覧', to: '/users', visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false }, diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index 7666bc8..b5b50ec 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -4,6 +4,7 @@ import { apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, + questionIdForCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, @@ -164,6 +165,27 @@ describe('expectedAnswerForQuestion', () => { expect(expectedAnswerForQuestion(question, post({ id: 1, title: 'short' }))).toBe('no') }) + + it('returns yes for matching title-contains questions', () => { + const question: StoredGekanatorQuestion = { + id: 'title:contains:結束バンド', + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + condition: { + type: 'title-contains', + text: '結束バンド', + }, + } + + expect(expectedAnswerForQuestion( + question, + post({ title: '結束バンドのライブ' }), + )).toBe('yes') + expect(expectedAnswerForQuestion( + question, + post({ title: '後藤ひとりの休日' }), + )).toBe('no') + }) }) describe('restoreGekanatorQuestion', () => { @@ -248,6 +270,21 @@ describe('restoreGekanatorQuestion', () => { expect(question.test(post({ title: 'x'.repeat(20) }))).toBe(false) expect(question.test(post({ title: 'x'.repeat(21) }))).toBe(true) }) + + it('restores title-contains questions with a title matcher', () => { + const question = restoreGekanatorQuestion({ + id: 'title:contains:結束バンド', + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + condition: { + type: 'title-contains', + text: '結束バンド', + }, + }) + + expect(question.test(post({ title: '結束バンドのライブ' }))).toBe(true) + expect(question.test(post({ title: '後藤ひとりの休日' }))).toBe(false) + }) }) describe('buildGekanatorQuestions', () => { @@ -264,6 +301,59 @@ describe('buildGekanatorQuestions', () => { expect(titleQuestion?.text).toMatch(/^タイトルは \d+ 文字以上\?$/) expect(titleQuestion?.id).toMatch(/^title:length-at-least:\d+$/) }) + + it('builds title-contains questions from repeated title words', () => { + const questions = buildGekanatorQuestions([ + post({ id: 1, title: '結束バンド ライブ' }), + post({ id: 2, title: '結束バンド 新曲' }), + post({ id: 3, title: '後藤ひとり 練習' }), + post({ id: 4, title: '伊地知虹夏 練習' }), + ]) + + const titleContainsQuestion = questions.find(question => + question.condition.type === 'title-contains' + && question.condition.text === '結束バンド') + + expect(titleContainsQuestion).toMatchObject({ + id: 'title:contains:結束バンド', + text: '題名に「結束バンド」が含まれる?', + kind: 'title', + source: 'default', + priorityWeight: .96, + }) + expect(titleContainsQuestion?.test(post({ title: '結束バンドのライブ' }))).toBe(true) + expect(titleContainsQuestion?.test(post({ title: '廣井きくりのライブ' }))).toBe(false) + }) + + it('honors question caps and title-contains toggles', () => { + const posts = [ + post({ id: 1, title: '結束バンド ライブ' }), + post({ id: 2, title: '結束バンド 新曲' }), + post({ id: 3, title: '後藤ひとり 練習' }), + post({ id: 4, title: '伊地知虹夏 練習' }), + ] + + const capped = buildGekanatorQuestions(posts, { + titleContainsCap: 1, + totalQuestionCap: 1, + }) + const withoutTitleContains = buildGekanatorQuestions(posts, { + includeTitleContains: false, + }) + + expect(capped).toHaveLength(1) + expect(withoutTitleContains.some(question => + question.condition.type === 'title-contains')).toBe(false) + }) +}) + +describe('questionIdForCondition', () => { + it('builds stable ids for title-contains questions', () => { + expect(questionIdForCondition({ + type: 'title-contains', + text: '結束バンド', + })).toBe('title:contains:結束バンド') + }) }) describe('Gekanator API writers', () => { diff --git a/frontend/src/lib/gekanator.ts b/frontend/src/lib/gekanator.ts index 9f4b62c..f6a5b7e 100644 --- a/frontend/src/lib/gekanator.ts +++ b/frontend/src/lib/gekanator.ts @@ -13,6 +13,7 @@ export type GekanatorAnswerLog = { questionId: string questionText: string questionCondition?: GekanatorQuestionCondition + questionMode?: 'normal' | 'winning_run' answer: GekanatorAnswerValue originalAnswer: GekanatorAnswerValue } @@ -29,6 +30,8 @@ export type GekanatorQuestionSource = | 'ai_generated' | 'admin_curated' +export type GekanatorPerformanceMode = 'lite' | 'normal' + export type GekanatorQuestionCondition = | { type: 'tag'; key: string } | { type: 'source'; host: string } @@ -38,6 +41,7 @@ export type GekanatorQuestionCondition = | { type: 'title-length-at-least'; length: number } | { type: 'title-length-greater-than'; length: number } | { type: 'title-has-ascii' } + | { type: 'title-contains'; text: string } | { type: 'post-similarity' postId: number @@ -76,6 +80,13 @@ export type GekanatorQuestion = { exampleAnswers?: Record<`${ number }`, GekanatorAnswerValue> test: (post: Post) => boolean } +export type BuildGekanatorQuestionsOptions = { + includeTitleContains?: boolean + tagQuestionCap?: number + titleContainsCap?: number + totalQuestionCap?: number +} + export const normalizeTitleLengthCondition = ( condition: GekanatorQuestionCondition, @@ -127,6 +138,8 @@ export const questionIdForCondition = ( return `title:length-at-least:${ titleLengthMinimumForCondition (condition) }` case 'title-has-ascii': return 'title:ascii' + case 'title-contains': + return `title:contains:${ condition.text }` } } @@ -292,6 +305,8 @@ const questionMatches = ( return (post.title?.length ?? 0) > question.condition.length case 'title-has-ascii': return /[A-Za-z0-9]/.test (post.title ?? '') + case 'title-contains': + return (post.title ?? '').includes (question.condition.text) case 'post-similarity': return false } @@ -319,6 +334,7 @@ export const expectedAnswerForQuestion = ( case 'title-length-at-least': case 'title-length-greater-than': case 'title-has-ascii': + case 'title-contains': return questionMatches (post, question) ? 'yes' : 'no' case 'post-similarity': return null @@ -382,7 +398,16 @@ export const fetchGekanatorExtraQuestions = async ( } -export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { +export const buildGekanatorQuestions = ( + posts: Post[], + options: BuildGekanatorQuestionsOptions = { }, +): GekanatorQuestion[] => { + const { + includeTitleContains = true, + tagQuestionCap = 192, + titleContainsCap = 24, + totalQuestionCap = Number.POSITIVE_INFINITY, + } = options const tagCounts = countBy (posts.flatMap (post => post.tags .filter (tag => @@ -404,17 +429,31 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { .map (originalMonthDayOf) .filter ((monthDay): monthDay is string => monthDay !== null)) const titleLengthMedian = median (posts.map (post => post.title?.length ?? 0)) + const titleWordCounts = + includeTitleContains + ? countBy ( + posts.flatMap (post => + Array.from ( + new Set ( + (post.title ?? '') + .match ( + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu) + ?? [])))) + : new Map () - const usefulEntries = (counts: Map) => + const usefulEntries = ( + counts: Map, + cap: number, + ) => [...counts.entries ()] .filter (([, count]) => count > 0 && count < posts.length) .sort ((a, b) => Math.abs (posts.length / 2 - a[1]) - Math.abs (posts.length / 2 - b[1])) - .slice (0, 80) + .slice (0, cap) - const tagQuestions = usefulEntries (tagCounts) + const tagQuestions = usefulEntries (tagCounts, Math.max (tagQuestionCap, 80)) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) - .slice (0, 80) + .slice (0, tagQuestionCap) .map (([key]) => { const { category, name } = tagFromQuestionKey (String (key)) const label = category === 'nico' ? nicoTagLabel (name) : name @@ -429,7 +468,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { test: (post: Post) => questionableTag (post, String (key)) } }) - const sourceQuestions = usefulEntries (hosts) + const sourceQuestions = usefulEntries (hosts, 20) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([host]) => ({ @@ -441,7 +480,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { priorityWeight: 1, test: (post: Post) => hostOf (post) === host })) - const originalYearQuestions = usefulEntries (originalYears) + const originalYearQuestions = usefulEntries (originalYears, 20) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([year]) => ({ @@ -453,7 +492,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { priorityWeight: 1, test: (post: Post) => originalYearOf (post) === year })) - const originalMonthQuestions = usefulEntries (originalMonths) + const originalMonthQuestions = usefulEntries (originalMonths, 20) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([month]) => ({ @@ -465,7 +504,7 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { priorityWeight: 1, test: (post: Post) => originalMonthOf (post) === month })) - const originalMonthDayQuestions = usefulEntries (originalMonthDays) + const originalMonthDayQuestions = usefulEntries (originalMonthDays, 20) .filter (([, count]) => count >= 2 && count <= Math.max (2, posts.length * .7)) .slice (0, 20) .map (([monthDay]) => { @@ -505,6 +544,23 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { const no = posts.length - yes return yes >= 2 && no >= 2 && yes <= posts.length * .7 && no <= posts.length * .7 }) + const titleContainsQuestions = + includeTitleContains + ? usefulEntries (titleWordCounts, titleContainsCap) + .filter (([word, count]) => + String (word).length <= 24 + && count >= 2 + && count <= Math.max (2, posts.length * .7)) + .slice (0, titleContainsCap) + .map (([word]) => ({ + id: `title:contains:${ word }`, + text: `題名に「${ word }」が含まれる?`, + kind: 'title' as const, + condition: { type: 'title-contains' as const, text: String (word) }, + source: 'default' as const, + priorityWeight: .96, + test: (post: Post) => (post.title ?? '').includes (String (word)) })) + : [] return [ ...sourceQuestions, @@ -512,7 +568,8 @@ export const buildGekanatorQuestions = (posts: Post[]): GekanatorQuestion[] => { ...originalMonthQuestions, ...originalMonthDayQuestions, ...titleQuestions, - ...tagQuestions] + ...titleContainsQuestions, + ...tagQuestions].slice (0, totalQuestionCap) } diff --git a/frontend/src/lib/gekanatorCandidateRecovery.test.ts b/frontend/src/lib/gekanatorCandidateRecovery.test.ts index 3716f3f..8e06f91 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.test.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.test.ts @@ -145,7 +145,30 @@ describe('recoverCandidatePosts', () => { expect(recovered?.recoveryStepCount).toBe (1) expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) - .toEqual ([8, 7, 6, 5, 4, 3, 2]) + .toEqual ([8, 7, 6, 5, 4]) expect(recovered?.recoveredCandidatePosts.get (7)).toBe (2) }) + + it('does not add posts when recovered and eligible candidates already hit the target', () => { + const posts = Array.from ({ length: 10 }, (_value, index) => post (index + 1)) + const scores = new Map (posts.map (candidate => [candidate.id, candidate.id])) + + const recovered = recoverCandidatePosts ({ + posts, + scores, + rejectedPostIds: new Set (), + recoveredCandidatePosts: new Map ([ + [1, 1], + [2, 1], + [3, 1], + ]), + eligiblePostIds: new Set ([4, 5, 6]), + answerCountAtRecovery: 2, + recoveryStepCount: 0, + }) + + expect(recovered?.recoveryStepCount).toBe (1) + expect([...(recovered?.recoveredCandidatePosts.keys () ?? [])]) + .toEqual ([1, 2, 3]) + }) }) diff --git a/frontend/src/lib/gekanatorCandidateRecovery.ts b/frontend/src/lib/gekanatorCandidateRecovery.ts index 9b89ab3..024ad0c 100644 --- a/frontend/src/lib/gekanatorCandidateRecovery.ts +++ b/frontend/src/lib/gekanatorCandidateRecovery.ts @@ -100,7 +100,7 @@ export const allConcreteAnswerOptionsExhausted = ( } -const nextRecoveryBatchSize = (recoveryStepCount: number): number => +const nextRecoveryTargetSize = (recoveryStepCount: number): number => 6 * (2 ** recoveryStepCount) @@ -125,6 +125,16 @@ export const recoverCandidatePosts = ({ recoveryStepCount: number } | null => { const recovered = new Map (recoveredCandidatePosts) + const targetSize = nextRecoveryTargetSize (recoveryStepCount) + const countedPostIds = new Set ([ + ...eligiblePostIds, + ...recovered.keys ()]) + const addCount = targetSize - countedPostIds.size + if (addCount <= 0) + return { + recoveredCandidatePosts: recovered, + recoveryStepCount: recoveryStepCount + 1 } + const candidates = posts .filter (post => !(rejectedPostIds.has (post.id)) @@ -133,7 +143,7 @@ export const recoverCandidatePosts = ({ .sort ((a, b) => (scores.get (b.id) ?? Number.NEGATIVE_INFINITY) - (scores.get (a.id) ?? Number.NEGATIVE_INFINITY)) - .slice (0, nextRecoveryBatchSize (recoveryStepCount)) + .slice (0, addCount) if (candidates.length === 0) return null diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 34cdf54..a033498 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -1,26 +1,24 @@ +import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' -import { buildGekanatorQuestions, - expectedAnswerForQuestion, +import { expectedAnswerForQuestion, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, fetchGekanatorPosts, normalizeTitleLengthCondition, + questionIdForCondition, restoreGekanatorQuestion, saveGekanatorExtraQuestionAnswers, saveGekanatorGame, saveGekanatorQuestionSuggestion, storeGekanatorQuestion, titleLengthMinimumForCondition } from '@/lib/gekanator' -import { allConcreteAnswerOptionsExhausted, - candidatePostsFor, - hardFilteredPostsForAnswer, - recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' +import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' import { isQuestionHardFilteredAfterAnswers, monthForCondition } from '@/lib/gekanatorQuestionFilters' import { gekanatorKeys } from '@/lib/queryKeys' @@ -31,11 +29,13 @@ import type { FC } from 'react' import type { GekanatorAnswerLog, GekanatorAnswerValue, GekanatorExtraQuestion, + GekanatorPerformanceMode, GekanatorQuestionCondition, + GekanatorQuestionKind, GekanatorQuestion, StoredGekanatorQuestion } from '@/lib/gekanator' import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery' -import type { Post } from '@/types' +import type { Post, User } from '@/types' type Phase = | 'intro' @@ -80,6 +80,7 @@ type GameSnapshot = { lastRejectedGuessId: number | null winningRunTargetId: number | null winningRunStartAnswerCount: number | null + guessReason: GuessReason | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null } @@ -103,6 +104,7 @@ type StoredGekanatorGame = { lastRejectedGuessId: number | null winningRunTargetId?: number | null winningRunStartAnswerCount?: number | null + guessReason?: GuessReason | null activeGuessId: number | null reviewGuessedPostId: number | null reviewCorrectPostId: number | null @@ -115,6 +117,33 @@ type StoredGekanatorGame = { extraQuestionAnswers?: Record extraQuestionState?: 'idle' | 'loading' | 'ready' | 'empty' | 'saved' } +type RecentGameSummary = { + correctPostId: number + firstQuestionId: string | null + savedAt: number } + +type BackgroundMotionMode = 'on' | 'calm' | 'off' + +type GuessReason = + | 'hard_max_questions' + | 'winning_run_finished' + | 'question_count_checkpoint' + | 'question_generation_stalled' + +type QuestionMode = + | 'winning_run' + | 'normal' + | null + +type MascotState = + | 'idle' + | 'thinking_far' + | 'thinking_mid' + | 'thinking_near' + | 'confident' + | 'celebrate' + | 'failed' + const answerOptions: AnswerOption[] = [ { label: 'はい', value: 'yes' }, { label: 'いいえ', value: 'no' }, @@ -125,16 +154,33 @@ const answerOptions: AnswerOption[] = [ const answerLabelFor = (value: GekanatorAnswerValue): string => answerOptions.find (option => option.value === value)?.label ?? value -const questionsBetweenGuesses = 25 -const minQuestionsBeforeCertainGuess = 5 -const certainGuessPercent = 99.5 -const runnerUpMaxPercent = .5 +const minQuestionsBeforeCertainGuess = 25 const hardMaxQuestions = 80 const winningRunQuestionLimit = 3 const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' +const recentGamesStorageKey = 'gekanator:recent-games:v1' +const backgroundMotionStorageKey = 'gekanator:background-motion:v1' +const performanceModeStorageKey = 'gekanator:performance-mode:v1' const maxQuestionSuggestionsPerGame = 3 +const maxStoredRecentGames = 12 +const mascotAssetByState: Record = { + idle: '/gekanator/mascot-idle.png', + thinking_far: '/gekanator/mascot-thinking-far.png', + thinking_mid: '/gekanator/mascot-thinking-mid.png', + thinking_near: '/gekanator/mascot-thinking-near.png', + confident: '/gekanator/mascot-confident.png', + celebrate: '/gekanator/mascot-celebrate.png', + failed: '/gekanator/mascot-failed.png' } +const mascotAltByState: Record = { + idle: '待機する洗澡鹿', + thinking_far: '遠くを見つめる洗澡鹿', + thinking_mid: '考え込む洗澡鹿', + thinking_near: '見通しが立ってきた洗澡鹿', + confident: '見通した顔の洗澡鹿', + celebrate: 'ご満悦の洗澡鹿', + failed: 'しょんぼりした洗澡鹿' } const sourcePriorityOffset = (question: GekanatorQuestion): number => { switch (question.source) @@ -172,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 @@ -185,26 +231,27 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ...game, answers: game.answers.map (answer => ({ ...answer, - questionId: normalizeStoredQuestionId ( - answer.questionId, - answer.questionCondition), - 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 => { @@ -244,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 @@ -290,6 +337,115 @@ const loadStoredGame = (): StoredGekanatorGame | null => { const isStoredPhase = (phase: Phase): boolean => phase !== 'intro' +const loadRecentGames = (): RecentGameSummary[] => { + try + { + const raw = localStorage.getItem (recentGamesStorageKey) + if (!(raw)) + return [] + + const parsed = JSON.parse (raw) + if (!(Array.isArray (parsed))) + return [] + + return parsed + .filter ((item): item is RecentGameSummary => + typeof item === 'object' + && item !== null + && Number.isInteger ((item as RecentGameSummary).correctPostId) + && (((item as RecentGameSummary).firstQuestionId === null) + || typeof (item as RecentGameSummary).firstQuestionId === 'string') + && Number.isFinite ((item as RecentGameSummary).savedAt)) + .sort ((a, b) => b.savedAt - a.savedAt) + .slice (0, maxStoredRecentGames) + } + catch + { + return [] + } +} + + +const storeRecentGameSummary = ( + summary: RecentGameSummary, +): RecentGameSummary[] => { + const next = [ + summary, + ...loadRecentGames ().filter (item => + item.savedAt !== summary.savedAt + && !( + item.correctPostId === summary.correctPostId + && item.firstQuestionId === summary.firstQuestionId))] + .slice (0, maxStoredRecentGames) + + try + { + localStorage.setItem (recentGamesStorageKey, JSON.stringify (next)) + } + catch + { + return next + } + + return next +} + + +const loadBackgroundMotionMode = ( + performanceMode?: GekanatorPerformanceMode, +): BackgroundMotionMode => { + const fallbackMode = + performanceMode === 'lite' ? 'off' + : performanceMode === 'normal' ? 'on' + : detectDefaultPerformanceMode () === 'lite' ? 'off' + : 'on' + try + { + const raw = localStorage.getItem (backgroundMotionStorageKey) + if (raw === 'off' || raw === 'calm' || raw === 'on') + return raw + + return fallbackMode + } + catch + { + return fallbackMode + } +} + + +const detectDefaultPerformanceMode = (): GekanatorPerformanceMode => { + if (typeof window === 'undefined') + return 'normal' + + const isMobileWidth = + typeof window.matchMedia === 'function' + ? window.matchMedia ('(max-width: 767px)').matches + : window.innerWidth <= 767 + const memory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory + if ((typeof memory === 'number' && memory <= 4) || isMobileWidth) + return 'lite' + + return 'normal' +} + + +const loadPerformanceMode = (): GekanatorPerformanceMode => { + try + { + const raw = localStorage.getItem (performanceModeStorageKey) + if (raw === 'lite' || raw === 'normal') + return raw + } + catch + { + return detectDefaultPerformanceMode () + } + + return detectDefaultPerformanceMode () +} + + const resettableExtraQuestionState = (): { extraQuestions: GekanatorExtraQuestion[] extraQuestionAnswers: Record @@ -380,6 +536,80 @@ const deltaForExpectedAnswer = ( } +const distributionEntropy = (weights: number[]): number => + weights.reduce ((sum, weight) => + weight <= 0 + ? sum + : sum - weight * Math.log2 (weight), 0) + + +const questionCategoryPenalty = ( + question: GekanatorQuestion, + answerCount: number, + repeatPenalty: number, +): number => { + const earlyFactor = Math.max (0, (3 - answerCount) / 3) + const titleLengthPenalty = + titleLengthMinimumForCondition (question.condition) === null + ? 0 + : (answerCount === 0 ? 8 : 3.5) * earlyFactor + + switch (question.kind) + { + case 'tag': + return -2.8 * earlyFactor + repeatPenalty + case 'post_similarity': + return -3.2 * earlyFactor + repeatPenalty + case 'title': + return 3.4 * earlyFactor + titleLengthPenalty + repeatPenalty + case 'source': + case 'original_date': + return 2.4 * earlyFactor + repeatPenalty + default: + return repeatPenalty + } +} + + +const relatedPostIdsOf = (post: Post): number[] => { + const siblingPosts = Object.values (post.siblingPosts ?? { }).flat () + + return [...new Set ([ + ...(post.related ?? []).map (related => related.id), + ...(post.parentPosts ?? []).map (parent => parent.id), + ...(post.childPosts ?? []).map (child => child.id), + ...siblingPosts.map (sibling => sibling.id)])] +} + + +const userPriorWeightsFor = ( + posts: Post[], + recentGames: RecentGameSummary[], +): Map => { + const postById = new Map (posts.map (post => [post.id, post])) + const weights = new Map () + const addWeight = (postId: number, weight: number) => { + if (!(postById.has (postId)) || weight <= 0) + return + + weights.set (postId, (weights.get (postId) ?? 0) + weight) + } + + recentGames.slice (0, 6).forEach ((game, index) => { + const baseWeight = Math.max (.24, 1 - index * .18) + addWeight (game.correctPostId, baseWeight) + + const correctPost = postById.get (game.correctPostId) + if (!(correctPost)) + return + + relatedPostIdsOf (correctPost).forEach (postId => addWeight (postId, baseWeight * .45)) + }) + + return weights +} + + const answerWeightFor = ( questionId: string, softenedQuestionIds: Set, @@ -400,19 +630,684 @@ const questionDifficulty = (question: GekanatorQuestion): number => { } +type GekanatorMatchIndex = Map> + +type GekanatorQuestionMaterialIndex = { + postById: Map + tagKeysByPostId: Map + postIdsByTagKey: Map> + titleTermsByPostId: Map + postIdsByTitleTerm: Map> + hostByPostId: Map + postIdsByHost: Map> + originalYearByPostId: Map + postIdsByOriginalYear: Map> + originalMonthByPostId: Map + postIdsByOriginalMonth: Map> + originalMonthDayByPostId: Map + postIdsByOriginalMonthDay: Map> + titleLengthByPostId: Map + titleAsciiPostIds: Set + titleLengthThresholdCache: Map> +} + +const titleTermPattern = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu + + +const addPostIdToIndex = ( + index: Map>, + key: K, + postId: number, +) => { + const current = index.get (key) + if (current) + { + current.add (postId) + return + } + + index.set (key, new Set ([postId])) +} + + +const buildMaterialIndex = ( + posts: Post[], +): GekanatorQuestionMaterialIndex => { + const postById = new Map () + const tagKeysByPostId = new Map () + const postIdsByTagKey = new Map> () + const titleTermsByPostId = new Map () + const postIdsByTitleTerm = new Map> () + const hostByPostId = new Map () + const postIdsByHost = new Map> () + const originalYearByPostId = new Map () + const postIdsByOriginalYear = new Map> () + const originalMonthByPostId = new Map () + const postIdsByOriginalMonth = new Map> () + const originalMonthDayByPostId = new Map () + const postIdsByOriginalMonthDay = new Map> () + const titleLengthByPostId = new Map () + const titleAsciiPostIds = new Set () + + posts.forEach (post => { + postById.set (post.id, post) + + const tagKeys = post.tags + .filter (tag => + tag.category !== 'meta' + && !(tag.name.includes ('タグ希望')) + && !(tag.name.includes ('bot操作'))) + .map (tag => `${ tag.category }:${ tag.name }`) + tagKeysByPostId.set (post.id, tagKeys) + tagKeys.forEach (key => addPostIdToIndex (postIdsByTagKey, key, post.id)) + + const titleTerms = Array.from ( + new Set ((post.title ?? '').match (titleTermPattern) ?? [])) + titleTermsByPostId.set (post.id, titleTerms) + titleTerms.forEach (term => addPostIdToIndex (postIdsByTitleTerm, term, post.id)) + + const host = (() => { + try + { + return new URL (post.url).hostname.replace (/^www\./, '') + } + catch + { + return null + } + }) () + hostByPostId.set (post.id, host) + if (host) + addPostIdToIndex (postIdsByHost, host, post.id) + + const originalValue = post.originalCreatedFrom || post.originalCreatedBefore + const date = + originalValue + ? new Date (originalValue) + : null + const validDate = + date && !(Number.isNaN (date.getTime ())) + ? date + : null + const originalYear = validDate?.getFullYear () ?? null + const originalMonth = + validDate + ? validDate.getMonth () + 1 + : null + const originalMonthDay = + validDate + ? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }` + : null + originalYearByPostId.set (post.id, originalYear) + originalMonthByPostId.set (post.id, originalMonth) + originalMonthDayByPostId.set (post.id, originalMonthDay) + if (originalYear !== null) + addPostIdToIndex (postIdsByOriginalYear, originalYear, post.id) + if (originalMonth !== null) + addPostIdToIndex (postIdsByOriginalMonth, originalMonth, post.id) + if (originalMonthDay !== null) + addPostIdToIndex (postIdsByOriginalMonthDay, originalMonthDay, post.id) + + const titleLength = post.title?.length ?? 0 + titleLengthByPostId.set (post.id, titleLength) + if (/[A-Za-z0-9]/.test (post.title ?? '')) + titleAsciiPostIds.add (post.id) + }) + + return { + postById, + tagKeysByPostId, + postIdsByTagKey, + titleTermsByPostId, + postIdsByTitleTerm, + hostByPostId, + postIdsByHost, + originalYearByPostId, + postIdsByOriginalYear, + originalMonthByPostId, + postIdsByOriginalMonth, + originalMonthDayByPostId, + postIdsByOriginalMonthDay, + titleLengthByPostId, + titleAsciiPostIds, + titleLengthThresholdCache: new Map> () } +} + +const indexedQuestionTextForTag = (key: string): string => { + const [category, ...rest] = key.split (':') + const name = rest.join (':') + const label = category === 'nico' ? name.replace (/^nico:/, '') : name + + switch (category) + { + case 'deerjikist': + return `ニジラーとして「${ label }」に関係している?` + case 'meme': + return `『${ label }』に関係しそう?` + case 'character': + return `「${ label }」というキャラクターが関係している?` + case 'material': + return `素材「${ label }」に関係している?` + case 'nico': + return `ニコニコに「${ label }」というタグがついている?` + default: + return `「${ label }」が含まれる?` + } +} + +const matchingPostIdsForCondition = ({ + condition, + materialIndex, +}: { + condition: GekanatorQuestionCondition + materialIndex: GekanatorQuestionMaterialIndex +}): Set | null => { + switch (condition.type) + { + case 'tag': + return materialIndex.postIdsByTagKey.get (condition.key) ?? new Set () + case 'source': + return materialIndex.postIdsByHost.get (condition.host) ?? new Set () + case 'original-year': + return materialIndex.postIdsByOriginalYear.get (condition.year) ?? new Set () + case 'original-month': + return materialIndex.postIdsByOriginalMonth.get (condition.month) ?? new Set () + case 'original-month-day': + return materialIndex.postIdsByOriginalMonthDay.get (condition.monthDay) ?? new Set () + case 'title-has-ascii': + return materialIndex.titleAsciiPostIds + case 'title-contains': + return materialIndex.postIdsByTitleTerm.get (condition.text) ?? new Set () + case 'title-length-at-least': + case 'title-length-greater-than': { + const threshold = + titleLengthMinimumForCondition (condition) + if (threshold === null) + return new Set () + + const cached = materialIndex.titleLengthThresholdCache.get (threshold) + if (cached) + return cached + + const matched = new Set () + materialIndex.titleLengthByPostId.forEach ((length, postId) => { + if (length >= threshold) + matched.add (postId) + }) + materialIndex.titleLengthThresholdCache.set (threshold, matched) + return matched + } + case 'post-similarity': + return null + } +} + +type QuestionMatchResolver = { + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex +} + +const buildGekanatorMatchIndex = ( + posts: Post[], + questions: GekanatorQuestion[], +): GekanatorMatchIndex => new Map ( + questions.map (question => [ + question.id, + new Set ( + posts + .filter (post => question.test (post)) + .map (post => post.id))])) + +const matchingPostIdsForQuestion = ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex, +}: QuestionMatchResolver): Set => { + const byCondition = matchingPostIdsForCondition ({ + condition: question.condition, + materialIndex }) + if (byCondition !== null) + return byCondition + + const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id) + if (matched) + return matched + + const computed = new Set ( + posts + .filter (post => question.test (post)) + .map (post => post.id)) + dynamicMatchIndex?.set (question.id, computed) + return computed +} + +const matchingPostCountInIds = ({ + candidateIds, + candidateIdSet, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex, +}: { + candidateIds: number[] + candidateIdSet?: Set + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex +}): number => { + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + const ids = candidateIdSet ?? new Set (candidateIds) + let count = 0 + + if (matched.size < ids.size) + matched.forEach (postId => { + if (ids.has (postId)) + ++count + }) + else + ids.forEach (postId => { + if (matched.has (postId)) + ++count + }) + + return count +} + +const matchingWeightInCandidates = ( + { candidates, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }: { candidates: { post: Post; weight: number }[] + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex }, +): number => { + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + + return candidates.reduce ((sum, item) => + sum + (matched.has (item.post.id) ? item.weight : 0), 0) +} + +const signatureForCandidateIds = ( + { candidateIds, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex, }: { candidateIds: number[] + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + question: GekanatorQuestion + dynamicMatchIndex?: GekanatorMatchIndex }, +): string => { + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + + return candidateIds.map (postId => matched.has (postId) ? '1' : '0').join ('') +} + +const postIdsForHardAnswer = ( + { candidateIds, + question, + answer, + posts, + materialIndex, + matchIndex, + dynamicMatchIndex }: { candidateIds: number[] + question: GekanatorQuestion + answer: GekanatorAnswerValue + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + dynamicMatchIndex?: GekanatorMatchIndex }, +): number[] => { + if (answer === 'unknown' + || answer === 'partial' + || answer === 'probably_no') + return candidateIds + + if (answer === 'yes') + { + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + return candidateIds.filter (postId => matched.has (postId)) + } + + if (answer === 'no') + { + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + return candidateIds.filter (postId => !(matched.has (postId))) + } + + return candidateIds +} + +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, + 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 buildQuestionsForCandidateIds = ( + { candidateIds, + materialIndex, + performanceMode, + acceptedQuestions }: { candidateIds: number[] + materialIndex: GekanatorQuestionMaterialIndex + performanceMode: GekanatorPerformanceMode + acceptedQuestions: GekanatorQuestion[] }, +): GekanatorQuestion[] => { + const total = candidateIds.length + if (total === 0) + return acceptedQuestions + + const tagCounts = new Map () + const hostCounts = new Map () + const yearCounts = new Map () + const monthCounts = new Map () + const monthDayCounts = new Map () + const titleTermCounts = new Map () + const titleLengths: number[] = [] + let asciiCount = 0 + + candidateIds.forEach (postId => { + materialIndex.tagKeysByPostId.get (postId)?.forEach (key => + tagCounts.set (key, (tagCounts.get (key) ?? 0) + 1)) + const host = materialIndex.hostByPostId.get (postId) + if (host) + hostCounts.set (host, (hostCounts.get (host) ?? 0) + 1) + const year = materialIndex.originalYearByPostId.get (postId) + if (year !== null && year !== undefined) + yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1) + const month = materialIndex.originalMonthByPostId.get (postId) + if (month !== null && month !== undefined) + monthCounts.set (month, (monthCounts.get (month) ?? 0) + 1) + const monthDay = materialIndex.originalMonthDayByPostId.get (postId) + if (monthDay) + monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1) + if (performanceMode === 'normal') + materialIndex.titleTermsByPostId.get (postId)?.forEach (term => + titleTermCounts.set (term, (titleTermCounts.get (term) ?? 0) + 1)) + const titleLength = materialIndex.titleLengthByPostId.get (postId) ?? 0 + titleLengths.push (titleLength) + if (materialIndex.titleAsciiPostIds.has (postId)) + ++asciiCount + }) + + const tagCap = + performanceMode === 'lite' + ? total >= 80 ? 96 : 64 + : total >= 120 ? 128 : 96 + const titleTermCap = + performanceMode === 'lite' + ? 0 + : total >= 80 ? 10 : total >= 24 ? 14 : 20 + const factCap = total >= 80 ? 8 : 12 + const sortedLengths = [...titleLengths].sort ((a, b) => a - b) + const titleLengthMedian = sortedLengths[Math.floor (sortedLengths.length / 2)] ?? 0 + + const questions: GekanatorQuestion[] = [] + + rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap }) + .forEach (([host]) => { + questions.push (buildIndexedQuestion ({ + condition: { type: 'source', host }, + text: `${ host } の投稿を思い浮かべている?`, + kind: 'source', + priorityWeight: 1, + materialIndex })) + }) + + rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap }) + .forEach (([year]) => { + questions.push (buildIndexedQuestion ({ + condition: { type: 'original-year', year }, + text: `オリジナルの投稿年は ${ year } 年?`, + kind: 'original_date', + priorityWeight: 1, + materialIndex })) + }) + + rankedEntriesForCounts ({ counts: monthCounts, total, cap: factCap }) + .forEach (([month]) => { + questions.push (buildIndexedQuestion ({ + condition: { type: 'original-month', month }, + text: `オリジナルの投稿月は ${ month } 月?`, + kind: 'original_date', + priorityWeight: 1, + materialIndex })) + }) + + rankedEntriesForCounts ({ counts: monthDayCounts, total, cap: factCap }) + .forEach (([monthDay]) => { + const [month, day] = String (monthDay).split ('-') + questions.push (buildIndexedQuestion ({ + condition: { type: 'original-month-day', monthDay: String (monthDay) }, + text: `オリジナルの投稿日は ${ month } 月 ${ day } 日?`, + kind: 'original_date', + priorityWeight: 1, + materialIndex })) + }) + + if (titleLengthMedian > 0) + questions.push (buildIndexedQuestion ({ + condition: { + type: 'title-length-at-least', + length: titleLengthMedian }, + text: `タイトルは ${ titleLengthMedian } 文字以上?`, + kind: 'title', + priorityWeight: 1, + materialIndex })) + + if (asciiCount > 0 && asciiCount < total) + questions.push (buildIndexedQuestion ({ + condition: { type: 'title-has-ascii' }, + text: '題名に英数字が混じっている?', + kind: 'title', + priorityWeight: 1, + materialIndex })) + + rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap }) + .forEach (([key]) => { + questions.push (buildIndexedQuestion ({ + condition: { type: 'tag', key }, + text: indexedQuestionTextForTag (key), + kind: 'tag', + priorityWeight: 1, + materialIndex })) + }) + + if (performanceMode === 'normal') + rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap }) + .filter (([term]) => String (term).length <= 24) + .forEach (([term]) => { + questions.push (buildIndexedQuestion ({ + condition: { type: 'title-contains', text: String (term) }, + text: `題名に「${ term }」が含まれる?`, + kind: 'title', + priorityWeight: .96, + materialIndex })) + }) + + return mergeQuestions ([...questions, ...acceptedQuestions]) +} + +const candidatePostsForState = ({ + posts, + questionById, + materialIndex, + matchIndex, + answers, + softenedQuestionIds, + rejectedPostIds, + recoveredCandidatePosts, +}: { + posts: Post[] + questionById: Map + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex + answers: GekanatorAnswerLog[] + softenedQuestionIds: Set + rejectedPostIds: Set + recoveredCandidatePosts: Map +}): Post[] => { + const dynamicMatchIndex = new Map> () + + return posts.filter (post => { + if (rejectedPostIds.has (post.id)) + return false + + const answerCountAtRecovery = recoveredCandidatePosts.get (post.id) + + return answers.every ((answer, index) => { + if (answerCountAtRecovery !== undefined && index < answerCountAtRecovery) + return true + if (softenedQuestionIds.has (answer.questionId)) + return true + if (!(answer.answer === 'yes' || answer.answer === 'no')) + return true + + const question = questionById.get (answer.questionId) + const condition = answer.questionCondition ?? question?.condition + if (!(condition)) + return true + + const matched = question + ? matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + : matchingPostIdsForCondition ({ + condition, + materialIndex }) + if (matched !== null) + return answer.answer === 'yes' + ? matched.has (post.id) + : !(matched.has (post.id)) + + if (!(question)) + return true + + const expected = expectedAnswerForQuestion (question, post) + return expected === null || expected === 'unknown' || expected === answer.answer + }) + }) +} + +const hasDiscriminatingHardSplitForQuestion = ({ + candidateIds, + question, + posts, + materialIndex, + matchIndex, +}: { + candidateIds: number[] + question: GekanatorQuestion | null + posts: Post[] + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex +}): boolean => { + if (!(question)) + return false + + const dynamicMatchIndex = new Map> () + const yesCount = matchingPostCountInIds ({ + candidateIds, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + const noCount = candidateIds.length - yesCount + + return yesCount > 0 && noCount > 0 +} + + const recalculateScores = ({ posts, questions, answers, softenedQuestionIds, + materialIndex, + matchIndex, }: { posts: Post[] questions: GekanatorQuestion[] answers: GekanatorAnswerLog[] softenedQuestionIds: Set + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex }): Map => { const questionById = new Map (questions.map (question => [question.id, question])) const nextScores = new Map () + const dynamicMatchIndex = new Map> () answers.forEach (answer => { const question = questionById.get (answer.questionId) @@ -420,8 +1315,19 @@ const recalculateScores = ({ return const weight = answerWeightFor (answer.questionId, softenedQuestionIds) + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) posts.forEach (post => { - const expected = expectedAnswerForQuestion (question, post) + const expected = + matched.has (post.id) + ? 'yes' + : question.condition.type === 'post-similarity' + ? expectedAnswerForQuestion (question, post) + : 'no' nextScores.set ( post.id, (nextScores.get (post.id) ?? 0) @@ -475,16 +1381,29 @@ const previewAnswer = ({ scores, question, answer, + materialIndex, + matchIndex, }: { posts: Post[] scores: Map question: GekanatorQuestion answer: GekanatorAnswerValue + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex }): AnswerPreview => { - const nextPosts = hardFilteredPostsForAnswer ({ - posts, + const postById = new Map (posts.map (post => [post.id, post])) + const dynamicMatchIndex = new Map> () + const nextPostIds = postIdsForHardAnswer ({ + candidateIds: posts.map (post => post.id), question, - answer }) + answer, + posts, + materialIndex, + matchIndex, + dynamicMatchIndex }) + const nextPosts = nextPostIds + .map (postId => postById.get (postId)) + .filter ((post): post is Post => post !== undefined) if (nextPosts.length === 0) return { answer, @@ -609,6 +1528,8 @@ const sameConditionValue = ( return condition.monthDay case 'title-has-ascii': return '' + case 'title-contains': + return condition.text case 'post-similarity': return `${ condition.postId }:${ condition.answer }:${ condition.threshold }` case 'title-length-at-least': @@ -701,6 +1622,11 @@ const chooseQuestion = ({ answers, askedIds, gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex, }: { posts: Post[] questions: GekanatorQuestion[] @@ -708,7 +1634,123 @@ const chooseQuestion = ({ answers: GekanatorAnswerLog[] askedIds: Set gameSeed: string + recentFirstQuestionPenaltyById: Map + userPriorWeights: Map + performanceMode: GekanatorPerformanceMode + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex }): GekanatorQuestion | null => { + const candidateIds = posts.map (post => post.id) + const candidateIdSet = new Set (candidateIds) + const dynamicMatchIndex = new Map> () + + const invertedSignature = (signature: string): string => + signature.replace (/[01]/g, value => value === '1' ? '0' : '1') + + const redundantSignatures = ( + candidates: Post[], + ): Set => { + const signatures = new Set () + questions + .filter (question => askedIds.has (question.id)) + .forEach (question => { + const signature = signatureForCandidateIds ({ + candidateIds: candidates.map (post => post.id), + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + signatures.add (signature) + signatures.add (invertedSignature (signature)) + }) + + return signatures + } + + if (performanceMode === 'lite') + { + const nonTagCount = + questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length + const ranked = questions + .filter (question => !(askedIds.has (question.id))) + .map (question => { + if (isQuestionHardFilteredAfterAnswers (question, answers)) + return null + + const yes = matchingPostCountInIds ({ + candidateIds, + candidateIdSet, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + const no = posts.length - yes + if (yes === 0 || no === 0) + return null + + const splitScore = Math.abs (posts.length / 2 - yes) / posts.length + const minSide = posts.length < 10 ? 1 : Math.max (2, Math.floor (posts.length * .08)) + const narrowPenalty = yes < minSide || no < minSide ? .18 : 0 + const tagPenalty = question.kind === 'tag' && nonTagCount < 3 ? .1 : 0 + const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) + const sourceBonus = sourcePriorityOffset (question) + const priorityBonus = priorityWeightOffset (question) + const categoryPenalty = questionCategoryPenalty (question, answers.length, 0) + + return { + question, + score: splitScore * 100 + + narrowPenalty + + tagPenalty + + contradictionPenalty + + sourceBonus + + priorityBonus + + categoryPenalty, + narrow: narrowPenalty > 0 } + }) + .filter ((item): item is { + question: GekanatorQuestion + score: number + narrow: boolean } => item !== null && Number.isFinite (item.score)) + .sort ((a, b) => { + if (a.score !== b.score) + return a.score - b.score + + return a.question.id.localeCompare (b.question.id) + }) + const pool = ( + ranked.some (item => !(item.narrow)) + ? ranked.filter (item => !(item.narrow)) + : ranked) + .slice (0, 8) + + if (pool.length === 0) + return null + + const bestScore = pool[0]?.score ?? 0 + const weightedPool = pool.map (item => ({ + ...item, + weight: Math.exp ((bestScore - item.score) / 1.6) })) + const totalPoolWeight = + weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1 + const seed = `${ gameSeed }:lite:${ [...askedIds].sort ().join ('|') }:${ + weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|') + }` + const target = deterministicUnitFloat (seed) * totalPoolWeight + let cumulative = 0 + + for (const item of weightedPool) + { + cumulative += item.weight + if (target <= cumulative) + return item.question + } + + return weightedPool[weightedPool.length - 1]?.question ?? null + } + const scoredPosts = posts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) @@ -720,36 +1762,22 @@ const chooseQuestion = ({ weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1 const normalisedWeightedPosts = weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight })) - - const signatureFor = ( - question: GekanatorQuestion, - candidates: { post: Post; score: number }[], - ): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('') - - const invertedSignature = (signature: string): string => - signature.replace (/[01]/g, value => value === '1' ? '0' : '1') - - const redundantSignatures = ( - candidates: { post: Post; score: number }[], - ): Set => { - const signatures = new Set () - questions - .filter (question => askedIds.has (question.id)) - .forEach (question => { - const signature = signatureFor (question, candidates) - signatures.add (signature) - signatures.add (invertedSignature (signature)) - }) - - return signatures - } + const weightedEntropy = distributionEntropy ( + normalisedWeightedPosts.map (item => item.weight)) const rank = ( questionsToRank: GekanatorQuestion[], candidates: { post: Post; score: number }[], weightedCandidates: { post: Post; score: number; weight: number }[], ) => { - const redundant = redundantSignatures (candidates) + const redundant = redundantSignatures (candidates.map (item => item.post)) + const candidateById = new Map (candidates.map (item => [item.post.id, item.post])) + const candidateIds = candidates.map (item => item.post.id) + const candidateIdSet = new Set (candidateIds) + const priorEntries = [...userPriorWeights.entries ()] + .filter (([postId]) => candidateById.has (postId)) + const priorWeightTotal = + priorEntries.reduce ((sum, [, weight]) => sum + weight, 0) const nonTagCount = questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length @@ -758,21 +1786,62 @@ const chooseQuestion = ({ if (isQuestionHardFilteredAfterAnswers (question, answers)) return null - const signature = signatureFor (question, candidates) + const signature = signatureForCandidateIds ({ + candidateIds, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) if (redundant.has (signature)) return null - const yes = signature.split ('').filter (value => value === '1').length + const yes = matchingPostCountInIds ({ + candidateIds, + candidateIdSet, + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) const no = candidates.length - yes if (yes === 0 || no === 0) return null - const yesWeight = weightedCandidates.reduce ( - (sum, item) => sum + (question.test (item.post) ? item.weight : 0), - 0) + const yesWeight = matchingWeightInCandidates ({ + candidates: weightedCandidates.map (item => ({ + post: item.post, + weight: item.weight })), + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) const noWeight = 1 - yesWeight if (yesWeight <= 0 || noWeight <= 0) return null + if (Math.min (yesWeight, noWeight) < .08) + return null + + const matched = matchingPostIdsForQuestion ({ + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + const yesPosteriorWeights = weightedCandidates + .filter (item => matched.has (item.post.id)) + .map (item => item.weight / yesWeight) + const noPosteriorWeights = weightedCandidates + .filter (item => !(matched.has (item.post.id))) + .map (item => item.weight / noWeight) + const infoGain = + weightedEntropy + - ( + yesWeight * distributionEntropy (yesPosteriorWeights) + + noWeight * distributionEntropy (noPosteriorWeights)) + if (infoGain < (candidates.length >= 10 ? .02 : .008)) + return null const weightedSplitScore = Math.abs (.5 - yesWeight) const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length @@ -782,6 +1851,29 @@ const chooseQuestion = ({ const contradictionPenalty = contradictionPenaltyFor ({ question, answers }) const sourceBonus = sourcePriorityOffset (question) const priorityBonus = priorityWeightOffset (question) + const repeatPenalty = + answers.length === 0 + ? (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5 + : 0 + const categoryPenalty = questionCategoryPenalty ( + question, + answers.length, + repeatPenalty) + const priorSplitScore = + priorWeightTotal <= 0 + ? null + : Math.abs ( + .5 - ( + priorEntries.reduce ( + (sum, [postId, weight]) => { + return sum + (matched.has (postId) ? weight : 0) + }, + 0) / priorWeightTotal)) + const priorBonus = + priorSplitScore === null + ? 0 + : Math.max (0, .22 - priorSplitScore) * -18 + const infoGainBonus = -Math.min (1.2, infoGain) * 4 return { question, score: weightedSplitScore * 100 @@ -790,7 +1882,10 @@ const chooseQuestion = ({ + narrowPenalty + contradictionPenalty + sourceBonus - + priorityBonus, + + priorityBonus + + categoryPenalty + + priorBonus + + infoGainBonus, narrow: narrowPenalty > 0 } }) .filter ((item): item is { @@ -807,7 +1902,7 @@ const chooseQuestion = ({ ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked) - .slice (0, 12) + .slice (0, 16) if (pool.length === 0) return null @@ -815,7 +1910,7 @@ const chooseQuestion = ({ const bestScore = pool[0]?.score ?? 0 const weightedPool = pool.map (item => ({ ...item, - weight: Math.exp ((bestScore - item.score) / 1.8) })) + weight: Math.exp ((bestScore - item.score) / (answers.length === 0 ? 2.8 : 2.1)) })) const totalPoolWeight = weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1 const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${ @@ -844,6 +1939,216 @@ const directWinningRunExampleAnswerFor = ( : question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null +const winningRunTagText = ( + category: string, + name: string, +): string => { + switch (category) + { + case 'nico': + return `ニコニコに「${ name.replace (/^nico:/, '') }」タグがついている?` + default: + return `「${ name }」タグがついている?` + } +} + + +const winningRunHostOf = (post: Post): string | null => { + try + { + return new URL (post.url).hostname.replace (/^www\./, '') + } + catch + { + return null + } +} + + +const winningRunOriginalDateOf = (post: Post): Date | null => { + const value = post.originalCreatedFrom || post.originalCreatedBefore + if (!(value)) + return null + + const date = new Date (value) + return Number.isNaN (date.getTime ()) ? null : date +} + + +const winningRunCandidateQuestionsFor = (targetPost: Post): GekanatorQuestion[] => { + const questions: GekanatorQuestion[] = [] + const addQuestion = (question: GekanatorQuestion | null) => { + if (question) + questions.push (question) + } + const title = targetPost.title ?? '' + const titleWords = + Array.from ( + new Set ( + title.match ( + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu) + ?? [])) + .filter (word => word.length <= 24) + .slice (0, 8) + const host = winningRunHostOf (targetPost) + const originalDate = winningRunOriginalDateOf (targetPost) + const originalYear = originalDate?.getFullYear () ?? null + const originalMonth = originalDate?.getMonth () ?? null + const originalDay = originalDate?.getDate () ?? null + const monthDay = + originalMonth === null || originalDay === null + ? null + : `${ originalMonth + 1 }-${ originalDay }` + const titleLength = title.length + + addQuestion ( + host === null + ? null + : { + id: questionIdForCondition ({ type: 'source', host }), + text: `${ host } の投稿を思い浮かべている?`, + kind: 'source', + condition: { type: 'source', host }, + source: 'default', + priorityWeight: 1.1, + test: post => winningRunHostOf (post) === host }) + addQuestion ( + originalYear === null + ? null + : { + id: questionIdForCondition ({ + type: 'original-year', + year: originalYear }), + text: `オリジナルの投稿年は ${ originalYear } 年?`, + kind: 'original_date', + condition: { type: 'original-year', year: originalYear }, + source: 'default', + priorityWeight: 1.05, + test: post => winningRunOriginalDateOf (post)?.getFullYear () === originalYear }) + addQuestion ( + originalMonth === null + ? null + : { + id: questionIdForCondition ({ + type: 'original-month', + month: originalMonth + 1 }), + text: `オリジナルの投稿月は ${ originalMonth + 1 } 月?`, + kind: 'original_date', + condition: { type: 'original-month', month: originalMonth + 1 }, + source: 'default', + priorityWeight: 1.02, + test: post => winningRunOriginalDateOf (post)?.getMonth () === originalMonth }) + addQuestion ( + monthDay === null + ? null + : { + id: questionIdForCondition ({ type: 'original-month-day', monthDay }), + text: `オリジナルの投稿日は ${ originalMonth! + 1 } 月 ${ originalDay! } 日?`, + kind: 'original_date', + condition: { type: 'original-month-day', monthDay }, + source: 'default', + priorityWeight: .98, + test: post => { + const postDate = winningRunOriginalDateOf (post) + return postDate !== null + && `${ postDate.getMonth () + 1 }-${ postDate.getDate () }` === monthDay + } }) + const winningRunTitleLengths = [ + Math.max (1, titleLength - 4), + titleLength, + titleLength + 4] + .filter ((length: number, index: number, values: number[]) => + titleLength > 0 + && length > 0 + && values.indexOf (length) === index) + winningRunTitleLengths.forEach ((length: number, index: number) => { + addQuestion ({ + id: questionIdForCondition ({ + type: 'title-length-at-least', + length }), + text: `タイトルは ${ length } 文字以上?`, + kind: 'title', + condition: { type: 'title-length-at-least', length }, + source: 'default', + priorityWeight: index === 1 ? 1.08 : 1.01, + test: post => (post.title?.length ?? 0) >= length }) + }) + addQuestion ({ + id: questionIdForCondition ({ type: 'title-has-ascii' }), + text: '題名に英数字が混じっている?', + kind: 'title', + condition: { type: 'title-has-ascii' }, + source: 'default', + priorityWeight: .96, + test: post => /[A-Za-z0-9]/.test (post.title ?? '') }) + titleWords.forEach (word => { + addQuestion ({ + id: questionIdForCondition ({ type: 'title-contains', text: word }), + text: `題名に「${ word }」が含まれる?`, + kind: 'title', + condition: { type: 'title-contains', text: word }, + source: 'default', + priorityWeight: 1.07, + test: post => (post.title ?? '').includes (word) }) + }) + + targetPost.tags + .filter (tag => + tag.category !== 'meta' + && !(tag.name.includes ('タグ希望')) + && !(tag.name.includes ('bot操作'))) + .slice (0, 20) + .forEach (tag => { + addQuestion ({ + id: questionIdForCondition ({ + type: 'tag', + key: `${ tag.category }:${ tag.name }` }), + text: winningRunTagText (tag.category, tag.name), + kind: 'tag', + condition: { type: 'tag', key: `${ tag.category }:${ tag.name }` }, + source: 'default', + priorityWeight: 1.12, + test: post => post.tags.some (candidate => + candidate.category === tag.category + && candidate.name === tag.name + && candidate.category !== 'meta' + && !(candidate.name.includes ('タグ希望')) + && !(candidate.name.includes ('bot操作'))) }) + }) + + void ([ + { + answer: 'yes' as const, + threshold: .9, + text: 'その投稿そのものと言ってよさそう?' }, + { + answer: 'partial' as const, + threshold: .6, + text: 'かなり近いイメージ?' }, + { + answer: 'no' as const, + threshold: .25, + text: '少し違う印象もある?' }]).forEach ((item, index) => { + addQuestion ({ + id: `winning-run:post-similarity:${ targetPost.id }:${ item.answer }:${ item.threshold }`, + text: item.text, + kind: 'post_similarity', + condition: { + type: 'post-similarity', + postId: targetPost.id, + answer: item.answer, + threshold: item.threshold }, + source: 'default', + priorityWeight: 1 - index * .04, + exampleAnswers: { + [String (targetPost.id) as `${ number }`]: item.answer }, + test: post => post.id === targetPost.id }) + }) + + return questions +} + + const winningRunPriorityFor = ( question: GekanatorQuestion, expected: GekanatorAnswerValue, @@ -854,16 +2159,13 @@ const winningRunPriorityFor = ( const directAnswer = directWinningRunExampleAnswerFor (question, targetPost) if (directAnswer === null) return null - if (expected === 'yes') - return 1 - if (expected === 'no') - return 3 - return null } if (expected === 'yes') return 0 - if (expected === 'no') + if (expected === 'partial') + return 1 + if (expected === 'no' || expected === 'probably_no') return 2 return null @@ -872,24 +2174,21 @@ const winningRunPriorityFor = ( const chooseWinningRunQuestion = ({ posts, - fallbackPosts, - questions, targetPost, - scores, answers, askedIds, - gameSeed, + materialIndex, + matchIndex, }: { posts: Post[] - fallbackPosts: Post[] - questions: GekanatorQuestion[] targetPost: Post - scores: Map answers: GekanatorAnswerLog[] askedIds: Set - gameSeed: string + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex }): GekanatorQuestion | null => { - const ranked = questions + const dynamicMatchIndex = new Map> () + const ranked = mergeQuestions (winningRunCandidateQuestionsFor (targetPost)) .filter (question => { if (askedIds.has (question.id)) return false @@ -908,7 +2207,13 @@ const chooseWinningRunQuestion = ({ if (priority === null) return null - const yesCount = posts.filter (post => question.test (post)).length + const yesCount = matchingPostCountInIds ({ + candidateIds: posts.map (post => post.id), + posts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) const matchingCount = expected === 'yes' || expected === 'partial' ? yesCount @@ -927,28 +2232,106 @@ const chooseWinningRunQuestion = ({ if (a.priority !== b.priority) return a.priority - b.priority - if (a.matchingCount !== b.matchingCount) - return a.matchingCount - b.matchingCount - if (a.question.priorityWeight !== b.question.priorityWeight) return b.question.priorityWeight - a.question.priorityWeight + if (a.matchingCount !== b.matchingCount) + return a.matchingCount - b.matchingCount + return a.question.id.localeCompare (b.question.id) }) if (ranked.length > 0) return ranked[0]?.question ?? null - return chooseQuestion ({ - posts: fallbackPosts.length > 0 ? fallbackPosts : posts, - questions, - scores, - answers, - askedIds, - gameSeed }) + return null } +const chooseFallbackQuestion = ({ + posts, + allPosts, + questions, + answers, + askedIds, + scores, + materialIndex, + matchIndex, +}: { + posts: Post[] + allPosts: Post[] + questions: GekanatorQuestion[] + answers: GekanatorAnswerLog[] + askedIds: Set + scores: Map + materialIndex: GekanatorQuestionMaterialIndex + matchIndex: GekanatorMatchIndex +}): GekanatorQuestion | null => { + if (posts.length === 0) + return null + + const fallbackPosts = posts + .map (post => ({ post, score: scores.get (post.id) ?? 0 })) + .sort ((a, b) => b.score - a.score) + .slice (0, Math.min (6, posts.length)) + .map (item => item.post) + const fallbackQuestions = mergeQuestions ( + fallbackPosts.flatMap (post => winningRunCandidateQuestionsFor (post))) + .slice (0, 32) + const dynamicMatchIndex = new Map> () + const candidateIds = posts.map (post => post.id) + const ranked = mergeQuestions ([ + ...questions, + ...fallbackQuestions]) + .filter (question => + !(askedIds.has (question.id)) + && !(isQuestionHardFilteredAfterAnswers (question, answers))) + .map (question => { + const yesCount = matchingPostCountInIds ({ + candidateIds, + posts: allPosts, + materialIndex, + matchIndex, + question, + dynamicMatchIndex }) + const noCount = candidateIds.length - yesCount + if (yesCount === 0 || noCount === 0) + return null + + return { + question, + knownCount: candidateIds.length, + balance: Math.abs (yesCount - noCount) } + }) + .filter ((item): item is { + question: GekanatorQuestion + knownCount: number + balance: number } => item !== null) + .sort ((a, b) => { + if (a.balance !== b.balance) + return a.balance - b.balance + + if (a.knownCount !== b.knownCount) + return b.knownCount - a.knownCount + + if (a.question.priorityWeight !== b.question.priorityWeight) + return b.question.priorityWeight - a.question.priorityWeight + + return a.question.id.localeCompare (b.question.id) + }) + + return ranked[0]?.question ?? null +} + + +const shouldEnterGuessPhase = ( + reason: GuessReason | null, +): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' => + (reason === 'hard_max_questions' + || reason === 'winning_run_finished' + || reason === 'question_count_checkpoint') + + const isWinningRunActive = ( winningRunTargetId: number | null, winningRunStartAnswerCount: number | null, @@ -961,7 +2344,200 @@ const winningRunQuestionCount = ( winningRunStartAnswerCount: number | null, ): number => winningRunStartAnswerCount === null ? 0 - : Math.max (0, answers.length - winningRunStartAnswerCount) + : answers + .slice (winningRunStartAnswerCount) + .filter (answer => answer.questionMode === 'winning_run') + .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 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 nextWinningRunTargetId = + eligiblePosts.length === 1 + ? eligiblePosts[0]?.id ?? null + : null + const nextWinningRunStartAnswerCount = + nextWinningRunTargetId === null + ? null + : ((isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + && winningRunTargetId === nextWinningRunTargetId + && winningRunStartAnswerCount !== null) + ? winningRunStartAnswerCount + : answers.length) + const nextWinningRunTargetPost = + nextWinningRunTargetId === null + ? null + : posts.find (post => post.id === nextWinningRunTargetId) ?? null + const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] => + buildQuestionsForCandidateIds ({ + candidateIds: scopePosts.map (post => post.id), + materialIndex, + performanceMode, + acceptedQuestions }) + + if (eligiblePosts.length === 1) + { + const winningRunFinished = + nextWinningRunTargetId !== null + && nextWinningRunStartAnswerCount !== null + && eligiblePosts[0]?.id === nextWinningRunTargetId + && winningRunQuestionCount ( + answers, + nextWinningRunStartAnswerCount) >= winningRunQuestionLimit + if (winningRunFinished) + return { + question: null, + guess: bestPost (eligiblePosts, scores), + guessReason: 'winning_run_finished', + questionMode: null, + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount === null) + return { + question: null, + guess: null, + guessReason: null, + questionMode: null, + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + + const winningRunQuestion = chooseWinningRunQuestion ({ + posts, + targetPost: nextWinningRunTargetPost, + answers, + askedIds, + materialIndex, + matchIndex }) + if (winningRunQuestion) + return { + question: winningRunQuestion, + guess: null, + guessReason: null, + questionMode: 'winning_run', + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + return { + question: null, + guess: null, + guessReason: null, + questionMode: null, + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + } + + const evaluationPosts = + eligiblePosts + + const evaluationQuestions = buildQuestionsForPosts (evaluationPosts) + const normalQuestion = chooseQuestion ({ + posts: evaluationPosts, + questions: evaluationQuestions, + scores, + answers, + askedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex }) + + const fallbackQuestion = normalQuestion ?? chooseFallbackQuestion ({ + posts: evaluationPosts, + allPosts: posts, + questions: evaluationQuestions, + answers, + askedIds, + scores, + materialIndex, + matchIndex }) + + if (fallbackQuestion) + { + return { + question: fallbackQuestion, + guess: null, + guessReason: null, + questionMode: 'normal', + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } + } + + return { + question: null, + guess: null, + guessReason: null, + questionMode: null, + winningRunTargetId: nextWinningRunTargetId, + winningRunStartAnswerCount: nextWinningRunStartAnswerCount } +} const bestPost = (posts: Post[], scores: Map): Post | null => @@ -989,6 +2565,534 @@ const PostMiniCard: FC<{ post: Post }> = ({ post }) => ( ) +const backgroundThumbnailUrl = (post: Post): string | undefined => + post.thumbnail || post.thumbnailBase || undefined + + +const mascotStateFor = ( + phase: Phase, + resultWon: boolean | null, + eligiblePostCount: number, + bestConfidencePercent: number, + winningRunActive: boolean, +): MascotState => { + const resultPhase = + phase === 'end' + || phase === 'review' + || phase === 'learned' + + if (resultPhase && !(resultWon)) + return 'failed' + + if (resultPhase && resultWon) + return 'celebrate' + + switch (phase) + { + case 'question': + case 'continue': + case 'extra_questions': + case 'question_suggestion': + if ( + winningRunActive + || eligiblePostCount <= 2 + || bestConfidencePercent >= 70 + ) + return 'thinking_near' + if ( + eligiblePostCount >= 15 + && bestConfidencePercent < 45 + ) + return 'thinking_far' + return 'thinking_mid' + case 'guess': + case 'end': + case 'review': + case 'learned': + return 'confident' + default: + return 'idle' + } +} + + +const backgroundPostsFor = ({ + phase, + eligiblePosts, + availablePosts, + displayedGuess, + reviewCorrectPost, + reviewGuessedPost, +}: { + phase: Phase + eligiblePosts: Post[] + availablePosts: Post[] + displayedGuess: Post | null + reviewCorrectPost: Post | null + reviewGuessedPost: Post | null +}): Post[] => { + const focusPosts = + phase === 'end' || phase === 'review' || phase === 'learned' + ? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post !== null) + : phase === 'guess' + ? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post !== null) + : eligiblePosts.length > 0 + ? eligiblePosts + : availablePosts + + return [...new Map (focusPosts.map (post => [post.id, post])).values ()] +} + + +const GekanatorBackdrop: FC<{ + posts: Post[] + mascotAsset: string + phase: Phase + displayedGuess?: Post | null + visualSeed: string + motionMode: BackgroundMotionMode + winningRunTargetPost?: Post | null + winningRunQuestionCount?: number }> = ({ posts, + mascotAsset, + phase, + displayedGuess = null, + visualSeed, + motionMode, + winningRunTargetPost = null, + winningRunQuestionCount = 0 }) => { + const guessFocusOffset = useMemo (() => { + const focusTiles = [ + { x: 'calc(max(100vw, 100vh) * 0.5)', + y: 'calc(max(100vw, 100vh) * 0.5)' }, + { x: 'calc(max(100vw, 100vh) * -0.5)', + y: 'calc(max(100vw, 100vh) * 0.5)' }, + { x: 'calc(max(100vw, 100vh) * 0.5)', + y: 'calc(max(100vw, 100vh) * -0.5)' }, + { x: 'calc(max(100vw, 100vh) * -0.5)', + y: 'calc(max(100vw, 100vh) * -0.5)' }] + + return (focusTiles[Math.abs (hashString (`${ visualSeed }:guess-focus`)) % focusTiles.length] + ?? focusTiles[0]) + }, [visualSeed]) + + const directions = useMemo ( + () => [ + { x: 0, y: -33.333333 }, + { x: 33.333333, y: -33.333333 }, + { x: 33.333333, y: 0 }, + { x: 33.333333, y: 33.333333 }, + { x: 0, y: 33.333333 }, + { x: -33.333333, y: 33.333333 }, + { x: -33.333333, y: 0 }, + { x: -33.333333, y: -33.333333 }], + []) + const guessThumbnail = + phase === 'guess' && displayedGuess + ? backgroundThumbnailUrl (displayedGuess) + : null + const isWinningRunBackdrop = + !(guessThumbnail) + && phase === 'question' + && winningRunTargetPost !== null + && Boolean (backgroundThumbnailUrl (winningRunTargetPost)) + const backdropMode = + guessThumbnail + ? 'guess' + : isWinningRunBackdrop + ? 'winning_run' + : 'normal' + + const normalVisiblePosts = useMemo ( + () => posts + .filter (post => Boolean (backgroundThumbnailUrl (post))) + .sort ((left, right) => + hashString (`${ visualSeed }:${ left.id }`) + - hashString (`${ visualSeed }:${ right.id }`)) + .slice (0, motionMode === 'calm' ? 24 : 36), + [posts, visualSeed, motionMode]) + + const settingsForMode = useCallback ( + ( + mode: 'normal' | 'winning_run' | 'guess', + ): { columns: number; rows: number; opacity: number } => { + if (mode === 'winning_run' || mode === 'guess') + return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 } + + return motionMode === 'calm' + ? { columns: 7, rows: 7, opacity: .14 } + : { columns: 10, rows: 10, opacity: .2 } + }, + [motionMode]) + + const scaleForMode = useCallback ( + ( + mode: 'normal' | 'winning_run' | 'guess', + displayedWinningCount: number, + ): number => { + if (mode === 'guess') + return 8 + + if (mode === 'winning_run') + return [1, 8 / 6, 8 / 4, 8 / 2][Math.max (0, Math.min (3, displayedWinningCount))] ?? 1 + + return 1 + }, + []) + + const postsForMode = useCallback (( + mode: 'normal' | 'winning_run' | 'guess', + ): Post[] => { + if (mode === 'guess' && displayedGuess) + return [displayedGuess] + if (mode === 'winning_run' && winningRunTargetPost) + return [winningRunTargetPost] + return normalVisiblePosts + }, [displayedGuess, winningRunTargetPost, normalVisiblePosts]) + + const thumbnailsForMode = useCallback (( + mode: 'normal' | 'winning_run' | 'guess', + count: number, + ): string[] => { + const modePosts = postsForMode (mode) + if (modePosts.length === 0) + return [] + + return Array.from ({ length: count }, (_, index) => { + const post = modePosts[index % modePosts.length] + return backgroundThumbnailUrl (post) ?? null + }).filter ((thumbnail): thumbnail is string => Boolean (thumbnail)) + }, [postsForMode]) + + const targetSettings = settingsForMode (backdropMode) + const targetTileCount = targetSettings.columns * targetSettings.rows + + const nextThumbnails = useMemo ( + () => thumbnailsForMode (backdropMode, targetTileCount), + [backdropMode, targetTileCount, thumbnailsForMode]) + + const nextDirection = useMemo ( + () => directions[ + Math.abs (hashString (`${ visualSeed }:direction`)) % directions.length] + ?? directions[0], + [visualSeed, directions]) + + const marqueeDuration = + backdropMode === 'winning_run' + ? motionMode === 'calm' ? 28 : 20 + : motionMode === 'calm' ? 34 : 24 + const tileFlipDuration = motionMode === 'calm' ? .6 : .45 + const x = useMotionValue (0) + const y = useMotionValue (0) + const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)` + const [activeDirection, setActiveDirection] = useState (nextDirection) + const activeDirectionRef = useRef (activeDirection) + const flipTimerRef = useRef (null) + const [displayedBackdropMode, setDisplayedBackdropMode] = + useState<'normal' | 'winning_run' | 'guess'> (backdropMode) + const [displayedWinningRunCount, setDisplayedWinningRunCount] = + useState (winningRunQuestionCount) + const [displayedThumbnails, setDisplayedThumbnails] = useState ( + nextThumbnails) + const [fromThumbnails, setFromThumbnails] = useState ( + nextThumbnails) + const [toThumbnails, setToThumbnails] = useState ( + nextThumbnails) + const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) + const [isFlippingTiles, setIsFlippingTiles] = useState (false) + const renderedSettings = settingsForMode (displayedBackdropMode) + const renderedTileCount = + renderedSettings.columns * renderedSettings.rows + const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) + const isGuessPresentation = + backdropMode === 'guess' || displayedBackdropMode === 'guess' + + useEffect (() => { + if (motionMode === 'off') + return + + if (!(isGuessPresentation)) + return + + const duration = motionMode === 'calm' ? .95 : .75 + const ease = [0.16, 1, 0.3, 1] as const + + const controls = [ + animate (x, 0, { duration, ease }), + animate (y, 0, { duration, ease })] + + return () => { + controls.forEach (control => control.stop ()) + } + }, [isGuessPresentation, motionMode, visualSeed, x, y]) + + useEffect (() => { + activeDirectionRef.current = activeDirection + }, [activeDirection]) + + useEffect (() => { + const wrap = (value: number): number => { + const cell = 33.333333 + const wrapped = ((value % cell) + cell) % cell + return wrapped > cell / 2 ? wrapped - cell : wrapped + } + + if (motionMode === 'off' || nextThumbnails.length === 0) + { + x.set (0) + y.set (0) + return + } + + if (isGuessPresentation) + return + + const speed = 33.333333 / marqueeDuration + let animationFrame: number + let previousTime = performance.now () + + const tick = (time: number) => { + const elapsedSeconds = (time - previousTime) / 1000 + previousTime = time + const direction = activeDirectionRef.current + x.set (wrap (x.get () + Math.sign (direction.x) * speed * elapsedSeconds)) + y.set (wrap (y.get () + Math.sign (direction.y) * speed * elapsedSeconds)) + animationFrame = window.requestAnimationFrame (tick) + } + + animationFrame = window.requestAnimationFrame (tick) + + return () => window.cancelAnimationFrame (animationFrame) + }, [ + x, + y, + marqueeDuration, + motionMode, + isGuessPresentation, + nextThumbnails.length]) + + useEffect (() => { + const applyDirection = () => { + activeDirectionRef.current = nextDirection + setActiveDirection (nextDirection) + } + + if (flipTimerRef.current !== null) { + window.clearTimeout (flipTimerRef.current) + flipTimerRef.current = null + } + + if (motionMode === 'off') { + applyDirection () + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } + + if (backdropMode === 'guess' && guessThumbnail) { + setIsFlippingTiles (false) + setDisplayedBackdropMode ('guess') + setDisplayedWinningRunCount (winningRunQuestionCount) + setDisplayedThumbnails (nextThumbnails) + setFromThumbnails (nextThumbnails) + setToThumbnails (nextThumbnails) + setFlipVisualSeed (visualSeed) + return + } + + if ( + displayedBackdropMode === 'winning_run' + && backdropMode === 'winning_run' + ) { + applyDirection () + setDisplayedBackdropMode ('winning_run') + setDisplayedWinningRunCount (winningRunQuestionCount) + setDisplayedThumbnails (nextThumbnails) + setFromThumbnails (nextThumbnails) + setToThumbnails (nextThumbnails) + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } + + if (nextThumbnails.length === 0) { + applyDirection () + setIsFlippingTiles (false) + setFlipVisualSeed (visualSeed) + return + } + + const sameTiles = + displayedThumbnails.length === nextThumbnails.length + && displayedThumbnails.every ( + (thumbnail, index) => thumbnail === nextThumbnails[index]) + if (sameTiles && flipVisualSeed === visualSeed) { + if ( + activeDirection.x !== nextDirection.x + || activeDirection.y !== nextDirection.y + ) + applyDirection () + return + } + + const currentThumbnails = + displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails + + setFromThumbnails (currentThumbnails) + setToThumbnails (nextThumbnails) + setIsFlippingTiles (true) + + flipTimerRef.current = window.setTimeout (() => { + setDisplayedBackdropMode (backdropMode) + setDisplayedWinningRunCount (winningRunQuestionCount) + setDisplayedThumbnails (nextThumbnails) + setFromThumbnails (nextThumbnails) + setToThumbnails (nextThumbnails) + setIsFlippingTiles (false) + applyDirection () + setFlipVisualSeed (visualSeed) + flipTimerRef.current = null + }, tileFlipDuration * 1000) + + return () => { + if (flipTimerRef.current !== null) { + window.clearTimeout (flipTimerRef.current) + flipTimerRef.current = null + } + } + }, [ + motionMode, + backdropMode, + displayedBackdropMode, + guessThumbnail, + nextThumbnails, + nextDirection, + displayedThumbnails, + flipVisualSeed, + visualSeed, + activeDirection, + winningRunQuestionCount, + tileFlipDuration, + x, + y]) + + if (motionMode === 'off' || nextThumbnails.length === 0) + return ( +
) + + return ( +
+
+ + + {Array.from ({ length: 9 }, (_, duplicate) => { + const column = duplicate % 3 + const row = Math.floor (duplicate / 3) + + return ( + + {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 ( + + {(displayedBackdropMode !== 'normal' || !(isFlippingTiles)) + ? ( + ) + : ( + + + + )} + ) + })} + ) + })} + + +
+
+
) +} + + const expectedAnswerFor = ( question: GekanatorQuestion | undefined, correctPost: Post | null, @@ -996,12 +3100,24 @@ const expectedAnswerFor = ( expectedAnswerForQuestion (question, correctPost) -const GekanatorPage: FC = () => { +const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const storedGame = useMemo (loadStoredGame, []) + const hasStoredRestore = storedGame !== null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () + const isAdmin = user?.role === 'admin' + const canPersistGame = user !== null + const [recentGames, setRecentGames] = useState ( + () => loadRecentGames ()) + const [performanceMode] = + useState (() => loadPerformanceMode ()) + const [backgroundMotionMode, setBackgroundMotionMode] = useState ( + () => loadBackgroundMotionMode (loadPerformanceMode ())) + const [prefersReducedMotion, setPrefersReducedMotion] = useState (false) const [gameSeed, setGameSeed] = useState ( storedGame?.gameSeed ?? createGameSeed ()) - const [phase, setPhase] = useState (storedGame?.phase ?? 'intro') + const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore) + const [phase, setPhase] = useState ( + hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro') const [scores, setScores] = useState> ( () => new Map (storedGame?.scores ?? [])) const [answers, setAnswers] = useState ( @@ -1036,6 +3152,8 @@ const GekanatorPage: FC = () => { storedGame?.winningRunTargetId ?? null) const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] = useState (storedGame?.winningRunStartAnswerCount ?? null) + const [guessReason, setGuessReason] = useState ( + storedGame?.guessReason ?? null) const [activeGuessId, setActiveGuessId] = useState ( storedGame?.activeGuessId ?? null) const [reviewGuessedPostId, setReviewGuessedPostId] = useState ( @@ -1074,6 +3192,10 @@ const GekanatorPage: FC = () => { queryFn: fetchGekanatorQuestions, select: questions => questions.map (restoreGekanatorQuestion), refetchOnWindowFocus: false }) + const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts]) + const acceptedQuestionMatchIndex = useMemo ( + () => buildGekanatorMatchIndex (posts, acceptedQuestions), + [posts, acceptedQuestions]) useEffect (() => { if ( @@ -1085,17 +3207,25 @@ const GekanatorPage: FC = () => { const questionById = new Map ( mergeQuestions ([ - ...buildGekanatorQuestions (posts), - ...acceptedQuestions]) + ...acceptedQuestions, + ...askedQuestionBank]) .map (question => [question.id, question])) setAskedQuestionBank ( storedAskedQuestionBankIds .map (questionId => questionById.get (questionId)) .filter ((question): question is GekanatorQuestion => question !== undefined)) setStoredAskedQuestionBankIds ([]) - }, [posts, storedAskedQuestionBankIds, acceptedQuestions, acceptedQuestionsFetched]) + }, [ + posts, + storedAskedQuestionBankIds, + acceptedQuestionsFetched, + askedQuestionBank, + acceptedQuestions]) useEffect (() => { + if (restorePromptVisible) + return + if (!(isStoredPhase (phase)) && answers.length === 0) { clearStoredGame () @@ -1121,6 +3251,7 @@ const GekanatorPage: FC = () => { lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, + guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, @@ -1160,6 +3291,7 @@ const GekanatorPage: FC = () => { lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, + guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId, @@ -1170,88 +3302,154 @@ const GekanatorPage: FC = () => { questionSuggestionCount, extraQuestions, extraQuestionAnswers, - extraQuestionState]) + extraQuestionState, + restorePromptVisible]) + useEffect (() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') + return + + const media = window.matchMedia ('(prefers-reduced-motion: reduce)') + const sync = () => setPrefersReducedMotion (media.matches) + + sync () + media.addEventListener ('change', sync) + return () => media.removeEventListener ('change', sync) + }, []) + + useEffect (() => { + try + { + localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode) + } + catch + { + return + } + }, [backgroundMotionMode]) + + useEffect (() => { + try + { + localStorage.setItem (performanceModeStorageKey, performanceMode) + } + catch + { + return + } + }, [performanceMode]) + + const askedQuestionById = useMemo ( + () => new Map (askedQuestionBank.map (question => [question.id, question])), + [askedQuestionBank]) const eligiblePosts = useMemo ( - () => candidatePostsFor ({ + () => candidatePostsForState ({ posts, - questions: askedQuestionBank, + questionById: askedQuestionById, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts }), - [posts, askedQuestionBank, answers, softenedQuestionIds, - rejectedPostIds, recoveredCandidatePosts]) - const questions = useMemo ( - () => mergeQuestions ([ - ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts), - ...acceptedQuestions]), - [acceptedQuestions, eligiblePosts, posts]) + [posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex, + answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts]) const scoringQuestions = useMemo (() => { - return mergeQuestions ([...questions, ...askedQuestionBank]) - }, [questions, askedQuestionBank]) + return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank]) + }, [acceptedQuestions, askedQuestionBank]) const scoringQuestionById = useMemo ( () => new Map (scoringQuestions.map (question => [question.id, question])), [scoringQuestions]) - const questionsSinceLastGuess = answers.length - lastGuessQuestionCount + const recentFirstQuestionPenaltyById = useMemo (() => { + if (performanceMode === 'lite') + return new Map () + + const penalties = new Map () + + recentGames.forEach ((game, index) => { + if (!(game.firstQuestionId)) + return + + penalties.set ( + game.firstQuestionId, + (penalties.get (game.firstQuestionId) ?? 0) + Math.max (.2, 1 - index * .22)) + }) + + return penalties + }, [performanceMode, recentGames]) + const userPriorWeights = useMemo ( + () => performanceMode === 'lite' + ? new Map () + : userPriorWeightsFor (posts, recentGames), + [performanceMode, posts, recentGames]) const availablePosts = useMemo ( () => posts.filter (post => !(rejectedPostIds.has (post.id))), [posts, rejectedPostIds]) + const questionPlan = useMemo ( + () => nextQuestionPlanFor ({ + posts, + eligiblePosts, + availablePosts, + acceptedQuestions, + scores, + answers, + askedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, + lastGuessQuestionCount, + winningRunTargetId, + winningRunStartAnswerCount }), + [posts, eligiblePosts, availablePosts, acceptedQuestions, scores, + answers, askedIds, gameSeed, recentFirstQuestionPenaltyById, + userPriorWeights, performanceMode, materialIndex, acceptedQuestionMatchIndex, + lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount]) const winningRunTargetPost = useMemo ( - () => winningRunTargetId === null + () => questionPlan.winningRunTargetId === null ? null - : posts.find (post => post.id === winningRunTargetId) ?? null, - [posts, winningRunTargetId]) + : posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null, + [posts, questionPlan.winningRunTargetId]) const winningRunQuestionsAsked = winningRunQuestionCount ( answers, - winningRunStartAnswerCount) + questionPlan.winningRunStartAnswerCount) const winningRunActive = - isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) + isWinningRunActive ( + questionPlan.winningRunTargetId, + questionPlan.winningRunStartAnswerCount) && winningRunQuestionsAsked < winningRunQuestionLimit && eligiblePosts.length === 1 - && eligiblePosts[0]?.id === winningRunTargetId + && eligiblePosts[0]?.id === questionPlan.winningRunTargetId && winningRunTargetPost !== null - const questionPosts = - eligiblePosts.length > 1 - || questionsSinceLastGuess >= minQuestionsBeforeCertainGuess - ? eligiblePosts - : availablePosts const topScoredPosts = useMemo ( () => eligiblePosts .map (post => ({ post, score: scores.get (post.id) ?? 0 })) .sort ((a, b) => b.score - a.score) .slice (0, 3), [eligiblePosts, scores]) - const currentQuestion = winningRunActive && winningRunTargetPost - ? chooseWinningRunQuestion ({ - posts, - fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, - questions: scoringQuestions, - targetPost: winningRunTargetPost, - scores, - answers, - askedIds, - gameSeed }) - : chooseQuestion ({ - posts: questionPosts, - questions: scoringQuestions, - scores, - answers, - askedIds, - gameSeed }) + const currentQuestion = questionPlan.question const answerPreviews = useMemo ( - () => currentQuestion + () => isAdmin && currentQuestion ? answerOptions.map (option => previewAnswer ({ posts: eligiblePosts, scores, question: currentQuestion, - answer: option.value })) + answer: option.value, + materialIndex, + matchIndex: acceptedQuestionMatchIndex })) : [], - [currentQuestion, eligiblePosts, scores]) + [isAdmin, currentQuestion, eligiblePosts, materialIndex, + acceptedQuestionMatchIndex, scores]) const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts + const guessConfidences = useMemo ( + () => confidencesFor (guessablePosts, scores), + [guessablePosts, scores]) + const bestConfidencePercent = guessConfidences[0]?.percent ?? 0 const guess = bestPost (guessablePosts, scores) const displayedGuess = posts.find (post => post.id === activeGuessId) ?? guess @@ -1259,9 +3457,51 @@ const GekanatorPage: FC = () => { posts.find (post => post.id === reviewGuessedPostId) ?? null const reviewCorrectPost = posts.find (post => post.id === reviewCorrectPostId) ?? null + const effectiveResultWon = + resultWon ?? ( + reviewGuessedPostId !== null + && reviewCorrectPostId !== null + ? reviewGuessedPostId === reviewCorrectPostId + : null) + const effectiveBackgroundMotionMode = + performanceMode === 'lite' + ? 'off' + : backgroundMotionMode === 'off' + ? 'off' + : prefersReducedMotion + ? 'calm' + : backgroundMotionMode + const backgroundPosts = useMemo ( + () => performanceMode === 'lite' + ? [] + : backgroundPostsFor ({ + phase, + eligiblePosts, + availablePosts, + displayedGuess, + reviewCorrectPost, + reviewGuessedPost }), + [performanceMode, phase, eligiblePosts, availablePosts, displayedGuess, + reviewCorrectPost, reviewGuessedPost]) + const backgroundVisualSeed = + performanceMode === 'lite' + ? '' + : `${ gameSeed }:${ phase }:${ answers.length }:${ activeGuessId ?? '' }:${ + questionPlan.question?.id ?? '' + }:${ questionPlan.questionMode ?? '' }:${ winningRunQuestionsAsked }:${ + rejectedPostIds.size + }:${ backgroundPosts.slice (0, 8).map (post => post.id).join ('|') }` + const mascot = mascotStateFor (phase, effectiveResultWon, eligiblePosts.length, + bestConfidencePercent, winningRunActive) + const mascotAsset = mascotAssetByState[mascot] + const mascotAlt = mascotAltByState[mascot] const saveMutation = useMutation ({ mutationFn: saveGekanatorGame, onSuccess: (data, variables) => { + setRecentGames (storeRecentGameSummary ({ + correctPostId: variables.correctPostId, + firstQuestionId: variables.answers[0]?.questionId ?? null, + savedAt: Date.now () })) setSaved (true) setSavedGameId (data.id) setResultWon (variables.guessedPostId === variables.correctPostId) @@ -1279,7 +3519,7 @@ const GekanatorPage: FC = () => { onSuccess: async () => { await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) setExtraQuestionState ('saved') - setPhase ('learned') + setPhase ('end') }}) const resetExtraQuestionState = () => { @@ -1294,6 +3534,7 @@ const GekanatorPage: FC = () => { clearStoredGame () saveMutation.reset () questionSuggestionMutation.reset () + setRestorePromptVisible (false) setPhase ('intro') setScores (new Map ()) setAnswers ([]) @@ -1311,6 +3552,7 @@ const GekanatorPage: FC = () => { setLastRejectedGuessId (null) setWinningRunTargetId (null) setWinningRunStartAnswerCount (null) + setGuessReason (null) setActiveGuessId (null) setReviewGuessedPostId (null) setReviewCorrectPostId (null) @@ -1323,7 +3565,12 @@ const GekanatorPage: FC = () => { setHistory ([]) } - const recoverQuestionState = ({ + const continueStoredGame = () => { + setRestorePromptVisible (false) + setPhase (storedGame?.phase ?? 'question') + } + + const recoverQuestionState = useCallback (({ nextAnswers, nextAskedIds, nextAskedQuestionBank, @@ -1345,6 +3592,8 @@ const GekanatorPage: FC = () => { let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds) let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts) let recoveredStepCount = nextRecoveryStepCount + const nextAskedQuestionById = + new Map (nextAskedQuestionBank.map (question => [question.id, question])) const answerCountAtRecovery = allowPreQuestionRecovery ? nextAnswers.length @@ -1353,18 +3602,25 @@ const GekanatorPage: FC = () => { posts, questions: nextAskedQuestionBank, answers: nextAnswers, - softenedQuestionIds: recoveredSoftenedQuestionIds }) - let recoveredEligiblePosts = candidatePostsFor ({ + softenedQuestionIds: recoveredSoftenedQuestionIds, + materialIndex, + matchIndex: acceptedQuestionMatchIndex }) + let recoveredEligiblePosts = candidatePostsForState ({ posts, - questions: nextAskedQuestionBank, + questionById: nextAskedQuestionById, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts }) + let recoveredQuestions = buildQuestionsForCandidateIds ({ + candidateIds: recoveredEligiblePosts.map (post => post.id), + materialIndex, + performanceMode, + acceptedQuestions }) let recoveredScoringQuestions = mergeQuestions ([ - ...buildGekanatorQuestions ( - recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), - ...acceptedQuestions, + ...recoveredQuestions, ...nextAskedQuestionBank]) const refreshRecoveredState = () => { @@ -1372,23 +3628,34 @@ const GekanatorPage: FC = () => { posts, questions: nextAskedQuestionBank, answers: nextAnswers, - softenedQuestionIds: recoveredSoftenedQuestionIds }) - recoveredEligiblePosts = candidatePostsFor ({ + softenedQuestionIds: recoveredSoftenedQuestionIds, + materialIndex, + matchIndex: acceptedQuestionMatchIndex }) + recoveredEligiblePosts = candidatePostsForState ({ posts, - questions: nextAskedQuestionBank, + questionById: nextAskedQuestionById, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, answers: nextAnswers, softenedQuestionIds: recoveredSoftenedQuestionIds, rejectedPostIds: nextRejectedPostIds, recoveredCandidatePosts }) + recoveredQuestions = buildQuestionsForCandidateIds ({ + candidateIds: recoveredEligiblePosts.map (post => post.id), + materialIndex, + performanceMode, + acceptedQuestions }) recoveredScoringQuestions = mergeQuestions ([ - ...buildGekanatorQuestions ( - recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts), - ...acceptedQuestions, + ...recoveredQuestions, ...nextAskedQuestionBank]) } const needsPreQuestionRecovery = () => { - if (!(allowPreQuestionRecovery) || recoveredEligiblePosts.length === 0) + if ( + !(allowPreQuestionRecovery) + || recoveredEligiblePosts.length === 0 + || recoveredEligiblePosts.length === 1 + ) return false const nextQuestion = chooseQuestion ({ @@ -1397,9 +3664,29 @@ const GekanatorPage: FC = () => { scores: recoveredScores, answers: nextAnswers, askedIds: nextAskedIds, - gameSeed }) + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex: acceptedQuestionMatchIndex }) + const fallbackQuestion = nextQuestion ?? chooseFallbackQuestion ({ + posts: recoveredEligiblePosts, + allPosts: posts, + questions: recoveredScoringQuestions, + answers: nextAnswers, + askedIds: nextAskedIds, + scores: recoveredScores, + materialIndex, + matchIndex: acceptedQuestionMatchIndex }) - return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion) + return !(fallbackQuestion) + || !(hasDiscriminatingHardSplitForQuestion ({ + candidateIds: recoveredEligiblePosts.map (post => post.id), + question: fallbackQuestion, + posts, + materialIndex, + matchIndex: acceptedQuestionMatchIndex })) } while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ()) @@ -1421,10 +3708,10 @@ const GekanatorPage: FC = () => { break } - if ( - recoveredEligiblePosts.length > 0 - || nextAnswers.length >= hardMaxQuestions - ) + if (nextAnswers.length >= hardMaxQuestions) + break + + if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ())) break const softened = softenNextQuestionIds ({ @@ -1445,13 +3732,26 @@ const GekanatorPage: FC = () => { scores: recoveredScores, eligiblePosts: recoveredEligiblePosts, scoringQuestions: recoveredScoringQuestions } - } + }, [ + posts, + gameSeed, + performanceMode, + materialIndex, + acceptedQuestions, + acceptedQuestionMatchIndex, + recentFirstQuestionPenaltyById, + userPriorWeights]) const answer = (value: GekanatorAnswerValue) => { if (!(currentQuestion)) { - setActiveGuessId (guess?.id ?? null) - setPhase ('guess') + if (questionPlan.guess && shouldEnterGuessPhase (questionPlan.guessReason)) + { + setActiveGuessId (questionPlan.guess.id) + setLastGuessQuestionCount (answers.length) + setGuessReason (questionPlan.guessReason) + setPhase ('guess') + } return } @@ -1471,6 +3771,7 @@ const GekanatorPage: FC = () => { lastRejectedGuessId, winningRunTargetId, winningRunStartAnswerCount, + guessReason, activeGuessId, reviewGuessedPostId, reviewCorrectPostId }]) @@ -1478,6 +3779,7 @@ const GekanatorPage: FC = () => { questionId: currentQuestion.id, questionText: currentQuestion.text, questionCondition: currentQuestion.condition, + questionMode: questionPlan.questionMode ?? undefined, answer: value, originalAnswer: value }] const nextAskedIds = new Set ([...askedIds, currentQuestion.id]) @@ -1492,73 +3794,84 @@ const GekanatorPage: FC = () => { nextRejectedPostIds: rejectedPostIds, nextRecoveredCandidatePosts: recoveredCandidatePosts, nextRecoveryStepCount: recoveryStepCount }) - const nextSoftenedQuestionIds = recovered.softenedQuestionIds - const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts - const nextScores = recovered.scores const nextEligiblePosts = recovered.eligiblePosts - const currentWinningRunActive = - isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) - const nextWinningRunTargetId = - nextEligiblePosts.length === 1 - ? nextEligiblePosts[0]?.id ?? null - : null - const nextWinningRunStartAnswerCount = - nextWinningRunTargetId === null - ? null - : currentWinningRunActive - && winningRunTargetId === nextWinningRunTargetId - && winningRunStartAnswerCount !== null - ? winningRunStartAnswerCount - : nextAnswers.length + let nextPlan = nextQuestionPlanFor ({ + posts, + eligiblePosts: nextEligiblePosts, + availablePosts, + acceptedQuestions, + scores: recovered.scores, + answers: nextAnswers, + askedIds: nextAskedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, + lastGuessQuestionCount, + winningRunTargetId, + winningRunStartAnswerCount }) + let finalRecovered = recovered - setScores (nextScores) + if ( + !(nextPlan.question) + && !(shouldEnterGuessPhase (nextPlan.guessReason)) + && recovered.eligiblePosts.length !== 1 + ) + { + const recoveredForQuestion = recoverQuestionState ({ + nextAnswers, + nextAskedIds, + nextAskedQuestionBank, + nextSoftenedQuestionIds: recovered.softenedQuestionIds, + nextRejectedPostIds: rejectedPostIds, + nextRecoveredCandidatePosts: recovered.recoveredCandidatePosts, + nextRecoveryStepCount: recovered.recoveryStepCount, + allowPreQuestionRecovery: true }) + nextPlan = nextQuestionPlanFor ({ + posts, + eligiblePosts: recoveredForQuestion.eligiblePosts, + availablePosts, + acceptedQuestions, + scores: recoveredForQuestion.scores, + answers: nextAnswers, + askedIds: nextAskedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, + lastGuessQuestionCount, + winningRunTargetId, + winningRunStartAnswerCount }) + finalRecovered = recoveredForQuestion + } + + setScores (finalRecovered.scores) setAskedIds (nextAskedIds) - setSoftenedQuestionIds (nextSoftenedQuestionIds) - setRecoveredCandidatePosts (nextRecoveredCandidatePosts) - setRecoveryStepCount (recovered.recoveryStepCount) + setSoftenedQuestionIds (finalRecovered.softenedQuestionIds) + setRecoveredCandidatePosts (finalRecovered.recoveredCandidatePosts) + setRecoveryStepCount (finalRecovered.recoveryStepCount) setAskedQuestionBank (nextAskedQuestionBank) setAnswers (nextAnswers) - setWinningRunTargetId (nextWinningRunTargetId) - setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) + setWinningRunTargetId (nextPlan.winningRunTargetId) + setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount) - const nextGuessablePosts = - nextEligiblePosts.length > 0 - ? nextEligiblePosts - : availablePosts - const nextGuess = bestPost (nextGuessablePosts, nextScores) - const nextQuestionCount = answers.length + 1 - const nextQuestionsSinceLastGuess = - nextQuestionCount - lastGuessQuestionCount - const nextConfidences = confidencesFor (nextGuessablePosts, nextScores) - const topConfidence = nextConfidences[0] ?? null - const runnerUpConfidence = nextConfidences[1] ?? null - const structurallyCertain = nextEligiblePosts.length === 1 - const winningRunContinues = - nextWinningRunTargetId !== null - && nextWinningRunStartAnswerCount !== null - && nextEligiblePosts.length === 1 - && winningRunQuestionCount ( - nextAnswers, - nextWinningRunStartAnswerCount) < winningRunQuestionLimit - const statisticallyCertain = - topConfidence !== null - && topConfidence.percent >= certainGuessPercent - && (runnerUpConfidence === null - || runnerUpConfidence.percent <= runnerUpMaxPercent) - const canGuessByQuestionCount = - nextQuestionsSinceLastGuess >= questionsBetweenGuesses - const canGuessEarlyByConfidence = - nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess - && (structurallyCertain || statisticallyCertain) - const shouldGuess = - nextQuestionCount >= hardMaxQuestions - || ( - !(winningRunContinues) - && (canGuessByQuestionCount || canGuessEarlyByConfidence)) - if (shouldGuess) + if (nextPlan.question) { - setActiveGuessId (nextGuess?.id ?? null) - setLastGuessQuestionCount (nextQuestionCount) + setGuessReason (null) + setActiveGuessId (null) + setPhase ('question') + return + } + + setGuessReason (nextPlan.guessReason) + if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason)) + { + setActiveGuessId (nextPlan.guess.id) + setLastGuessQuestionCount (nextAnswers.length) setPhase ('guess') } } @@ -1601,7 +3914,8 @@ const GekanatorPage: FC = () => { const saveReviewedResult = (onSuccess: (gameId: number) => void) => { if ( - reviewGuessedPostId === null + !(canPersistGame) + || reviewGuessedPostId === null || reviewCorrectPostId === null || saveMutation.isPending ) @@ -1621,16 +3935,7 @@ const GekanatorPage: FC = () => { } const saveAndReset = () => { - saveReviewedResult (reset) - } - - const saveAndLearn = () => { - resetExtraQuestionState () - saveReviewedResult (() => setPhase ('learned')) - } - - const restartFromQuestionSuggestion = () => { - if (savedGameId !== null) + if (!(canPersistGame)) { reset () return @@ -1639,10 +3944,22 @@ const GekanatorPage: FC = () => { saveReviewedResult (reset) } + const saveAndLearn = () => { + if (!(canPersistGame)) + { + setPhase ('end') + return + } + + resetExtraQuestionState () + saveReviewedResult (() => setPhase ('end')) + } + const submitQuestionSuggestion = () => { const questionText = questionSuggestion.trim () if ( - !(questionText) + !(canPersistGame) + || !(questionText) || questionSuggestionMutation.isPending || questionSuggestionCount >= maxQuestionSuggestionsPerGame ) @@ -1656,6 +3973,22 @@ const GekanatorPage: FC = () => { }) } + const saveExtraQuestions = () => { + if ( + !(canPersistGame) + || savedGameId === null + || extraQuestionAnswersMutation.isPending + || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) + ) + return + + extraQuestionAnswersMutation.mutate ({ + gameId: savedGameId, + answers: extraQuestions.map (question => ({ + questionId: question.id, + answer: extraQuestionAnswers[String (question.id)] })) }) + } + const rejectGuess = () => { if (!(displayedGuess)) return @@ -1674,6 +4007,7 @@ const GekanatorPage: FC = () => { ([postId]) => postId !== displayedGuess.id))) setWinningRunTargetId (null) setWinningRunStartAnswerCount (null) + setGuessReason (null) setActiveGuessId (null) setSearch ('') setSelectingCorrectPost (false) @@ -1701,6 +4035,7 @@ const GekanatorPage: FC = () => { setLastRejectedGuessId (snapshot.lastRejectedGuessId) setWinningRunTargetId (snapshot.winningRunTargetId) setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount) + setGuessReason (snapshot.guessReason) setActiveGuessId (snapshot.activeGuessId) setReviewGuessedPostId (snapshot.reviewGuessedPostId) setReviewCorrectPostId (snapshot.reviewCorrectPostId) @@ -1725,63 +4060,42 @@ const GekanatorPage: FC = () => { setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) setRecoveryStepCount (recovered.recoveryStepCount) setScores (recovered.scores) - const nextWinningRunTargetId = - recovered.eligiblePosts.length === 1 - ? recovered.eligiblePosts[0]?.id ?? null - : null - const nextWinningRunStartAnswerCount = - nextWinningRunTargetId === null - ? null - : isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount) - && winningRunTargetId === nextWinningRunTargetId - && winningRunStartAnswerCount !== null - ? winningRunStartAnswerCount - : answers.length - const nextWinningRunTargetPost = - nextWinningRunTargetId === null - ? null - : posts.find (post => post.id === nextWinningRunTargetId) ?? null + const nextPlan = nextQuestionPlanFor ({ + posts, + eligiblePosts: recovered.eligiblePosts, + availablePosts, + acceptedQuestions, + scores: recovered.scores, + answers, + askedIds, + gameSeed, + recentFirstQuestionPenaltyById, + userPriorWeights, + performanceMode, + materialIndex, + matchIndex: acceptedQuestionMatchIndex, + lastGuessQuestionCount, + winningRunTargetId, + winningRunStartAnswerCount }) - const recoveredGuessablePosts = - recovered.eligiblePosts.length > 0 - ? recovered.eligiblePosts - : availablePosts - const nextQuestion = - nextWinningRunTargetPost - && nextWinningRunStartAnswerCount !== null - && winningRunQuestionCount ( - answers, - nextWinningRunStartAnswerCount) < winningRunQuestionLimit - ? chooseWinningRunQuestion ({ - posts, - fallbackPosts: availablePosts.length > 1 ? availablePosts : posts, - questions: recovered.scoringQuestions, - targetPost: nextWinningRunTargetPost, - scores: recovered.scores, - answers, - askedIds, - gameSeed }) - : chooseQuestion ({ - posts: recovered.eligiblePosts.length > 1 - ? recovered.eligiblePosts - : availablePosts, - questions: recovered.scoringQuestions, - scores: recovered.scores, - answers, - askedIds, - gameSeed }) + setWinningRunTargetId (nextPlan.winningRunTargetId) + setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount) - setWinningRunTargetId (nextWinningRunTargetId) - setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount) - - if (nextQuestion) + if (nextPlan.question) { + setGuessReason (null) + setActiveGuessId (null) setPhase ('question') return } - setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.id ?? null) - setPhase ('guess') + setGuessReason (nextPlan.guessReason) + if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason)) + { + setActiveGuessId (nextPlan.guess.id) + setLastGuessQuestionCount (answers.length) + setPhase ('guess') + } } const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => { @@ -1868,25 +4182,19 @@ const GekanatorPage: FC = () => { [String (questionId)]: value }) } - const saveExtraQuestions = () => { - if ( - savedGameId === null - || extraQuestionAnswersMutation.isPending - || extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])) - ) - return + const introDialogue = + <>私は洗澡鹿シーザオグカ。質問から投稿を何でも当ててみせるよ。 - extraQuestionAnswersMutation.mutate ({ - gameId: savedGameId, - answers: extraQuestions.map (question => ({ - questionId: question.id, - answer: extraQuestionAnswers[String (question.id)] })) }) - } + const winDialogue = + <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! + + const loseDialogue = + <>ぬわーん! 洗澡鹿シーザオグカ外しちゃったグカー!!!!! + + const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue + + const dialogue = phase === 'learned' ? resultDialogue : introDialogue - const dialogue = - phase === 'learned' && resultWon - ? <>グカカカカwwwww 洗澡鹿シーザオグカは何でもお見通し! - : <>私は洗澡鹿シーザオグカ.質問から投稿を何でも当ててみせるよ const introLoading = isLoading || acceptedQuestionsLoading const readyToStart = !(introLoading) @@ -1895,6 +4203,54 @@ const GekanatorPage: FC = () => { && !(error) && !(acceptedQuestionsError) + useEffect (() => { + if ( + phase !== 'question' + || currentQuestion + || isLoading + || acceptedQuestionsLoading + || shouldEnterGuessPhase (questionPlan.guessReason) + || eligiblePosts.length === 1 + ) + return + + const recovered = recoverQuestionState ({ + nextAnswers: answers, + nextAskedIds: askedIds, + nextAskedQuestionBank: askedQuestionBank, + nextSoftenedQuestionIds: softenedQuestionIds, + nextRejectedPostIds: rejectedPostIds, + nextRecoveredCandidatePosts: recoveredCandidatePosts, + nextRecoveryStepCount: recoveryStepCount, + allowPreQuestionRecovery: true }) + + if ( + recovered.recoveryStepCount === recoveryStepCount + && recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size + && recovered.softenedQuestionIds.size === softenedQuestionIds.size + ) + return + + setSoftenedQuestionIds (recovered.softenedQuestionIds) + setRecoveredCandidatePosts (recovered.recoveredCandidatePosts) + setRecoveryStepCount (recovered.recoveryStepCount) + setScores (recovered.scores) + }, [ + phase, + currentQuestion, + questionPlan, + answers, + askedIds, + askedQuestionBank, + softenedQuestionIds, + rejectedPostIds, + recoveredCandidatePosts, + recoveryStepCount, + eligiblePosts, + recoverQuestionState, + isLoading, + acceptedQuestionsLoading]) + useEffect (() => { if ( phase !== 'question' @@ -1903,75 +4259,143 @@ const GekanatorPage: FC = () => { ) return - const winningRunFinished = - winningRunTargetId !== null - && winningRunStartAnswerCount !== null - && winningRunQuestionCount (answers, winningRunStartAnswerCount) >= winningRunQuestionLimit - && eligiblePosts.length === 1 - && eligiblePosts[0]?.id === winningRunTargetId - const nextGuess = displayedGuess ?? guess - if (currentQuestion || (!(winningRunFinished) && !(nextGuess))) + if ( + currentQuestion + || !(questionPlan.guess) + || !(shouldEnterGuessPhase (questionPlan.guessReason)) + ) return - setActiveGuessId ((winningRunFinished ? guess : nextGuess)?.id ?? null) + setWinningRunTargetId (questionPlan.winningRunTargetId) + setWinningRunStartAnswerCount (questionPlan.winningRunStartAnswerCount) + setActiveGuessId (questionPlan.guess.id) setLastGuessQuestionCount (answers.length) + setGuessReason (questionPlan.guessReason) setPhase ('guess') }, [ phase, currentQuestion, - guess, - displayedGuess, + questionPlan, answers, - eligiblePosts, - winningRunTargetId, - winningRunStartAnswerCount, isLoading, acceptedQuestionsLoading]) return ( - + - {`グカネータ | ${ SITE_TITLE }`} -
-
-

おたのしみ

-

- グカネータ -

+ {performanceMode !== 'lite' && ( + )} + +
+
+
+

+ グカネータ +

+
+
+ {performanceMode === 'normal' && ( +
+ + 背景 + + {[{ mode: 'off' as const, label: 'オフ' }, + { mode: 'on' as const, label: 'オン' }] + .map (({ mode, label }) => ( + ))} + {prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && ( + + 端末設定により控えめ表示 + )} +
)} +
-
-
-
- 洗澡鹿 +
+
+
+
+ {mascotAlt} +
-

- {dialogue} -

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

+ {dialogue} +

)} {introLoading && (

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

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

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

} - {phase === 'intro' && readyToStart && ( + {phase === 'intro' && readyToStart && restorePromptVisible && ( +
+ + +
)} + + {phase === 'intro' && readyToStart && !(restorePromptVisible) && ( )} {phase === 'question' && currentQuestion && ( @@ -1982,18 +4406,32 @@ const GekanatorPage: FC = () => {

{currentQuestion.text}

-
-
現在候補: {eligiblePosts.length} 件
- {topScoredPosts.length > 0 && ( -
- {topScoredPosts.map (item => ( - - #{item.post.id}: score {item.score.toFixed (1)} - ))} -
)} -
- {answerPreviews.length > 0 && ( + {isAdmin && ( +
+
現在候補: {eligiblePosts.length} 件
+
+ winningRunTargetId: {String (questionPlan.winningRunTargetId)} + {' / '} + winningRunQuestionCount: {winningRunQuestionsAsked} + {' / '} + guessReason: {guessReason ?? '-'} + {' / '} + questionMode: {questionPlan.questionMode ?? '-'} + {' / '} + recoveryStepCount: {recoveryStepCount} + {' / '} + currentQuestion===null: {String (currentQuestion === null)} +
+ {topScoredPosts.length > 0 && ( +
+ {topScoredPosts.map (item => ( + + #{item.post.id}: score {item.score.toFixed (1)} + ))} +
)} +
)} + {isAdmin && answerPreviews.length > 0 && (
{answerOptions.map (option => { const preview = @@ -2006,8 +4444,13 @@ const GekanatorPage: FC = () => { {option.label} {' '} - なら候補 {preview ? preview.candidateCount : 0} 件 + 候補 {preview ? preview.candidateCount : 0} 件 +
+ effective {preview?.effectiveCandidates.toFixed (2) ?? '0.00'} + {' / '} + entropy {preview?.entropy.toFixed (2) ?? '0.00'} +
) })}
)} @@ -2033,10 +4476,42 @@ const GekanatorPage: FC = () => { )}
)} - + {phase === 'question' && !(currentQuestion) && isAdmin && ( +
+
question stalled
+
+ winningRunTargetId: {String (questionPlan.winningRunTargetId)} + {' / '} + winningRunQuestionCount: {winningRunQuestionsAsked} + {' / '} + guessReason: {questionPlan.guessReason ?? '-'} + {' / '} + questionMode: {questionPlan.questionMode ?? '-'} + {' / '} + recoveryStepCount: {recoveryStepCount} + {' / '} + candidateCount: {eligiblePosts.length} +
+
)} {phase === 'guess' && displayedGuess && (
-

これを想像してゐたね?

+

思い浮かべているのは、これだね?

+ {isAdmin && ( +
+ winningRunTargetId: {String (questionPlan.winningRunTargetId)} + {' / '} + winningRunQuestionCount: {winningRunQuestionsAsked} + {' / '} + guessReason: {guessReason ?? '-'} + {' / '} + questionMode: {questionPlan.questionMode ?? '-'} + {' / '} + recoveryStepCount: {recoveryStepCount} + {' / '} + currentQuestion===null: {String (currentQuestion === null)} +
)}
{history.length > 0 && ( @@ -2164,8 +4650,9 @@ const GekanatorPage: FC = () => { 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={saveMutation.isPending + dark:hover:bg-red-900 disabled:opacity-50" + disabled={!(canPersistGame) + || saveMutation.isPending || questionSuggestionMutation.isPending} onClick={() => setPhase ('question_suggestion')}> 質問を追加 @@ -2175,12 +4662,13 @@ const GekanatorPage: FC = () => { 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 + disabled={!(canPersistGame) + || reviewCorrectPostId === null || saveMutation.isPending || extraQuestionState === 'loading' || extraQuestionAnswersMutation.isPending} onClick={startExtraQuestions}> - 追加で質問に答へる + 追加で質問に答える
)} @@ -2188,8 +4676,7 @@ const GekanatorPage: FC = () => { {phase === 'review' && (
-

保存前確認

-

今回の結果を確認してね。

+

結果修正

{reviewGuessedPost && ( @@ -2264,7 +4751,8 @@ const GekanatorPage: FC = () => { {reviewGuessedPostId !== null && reviewCorrectPostId !== null && (

- 判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : '違ひ'} + 判定: + {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : 'はずれ'}

)} {saveMutation.isError && ( @@ -2273,18 +4761,6 @@ const GekanatorPage: FC = () => {

)}
-
)} @@ -2334,8 +4823,9 @@ const GekanatorPage: FC = () => { 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={saveMutation.isPending + dark:hover:bg-red-900 disabled:opacity-50" + disabled={!(canPersistGame) + || saveMutation.isPending || questionSuggestionMutation.isPending} onClick={() => setPhase ('end')}> 戻る @@ -2346,6 +4836,8 @@ const GekanatorPage: FC = () => { hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900 disabled:opacity-50" disabled={ + !(canPersistGame) + || questionSuggestionCount >= maxQuestionSuggestionsPerGame || reviewCorrectPostId === null || questionSuggestion.trim () === '' @@ -2355,20 +4847,15 @@ const GekanatorPage: FC = () => { onClick={submitQuestionSuggestion}> 追加 -
{questionSuggestionCount >= maxQuestionSuggestionsPerGame && (

このゲームでは質問候補をこれ以上追加できません。

)} + {!(canPersistGame) && ( +

+ 未ログインのため質問追加は送信できません。 +

)} {(saveMutation.isError || questionSuggestionMutation.isError) && (

記録できませんでした。通信状態を確認してもう一度試して。 @@ -2379,11 +4866,11 @@ const GekanatorPage: FC = () => {

追加学習

-

追加で 2 問まで答へてください。

+

追加で 2 問まで答えてください。

{extraQuestionState === 'loading' && ( -

追加質問を読み込んでゐます...

)} +

追加質問を読み込んでいます……

)} {extraQuestionState === 'empty' && (

追加で学習できる質問はありませんでした。

)} @@ -2436,27 +4923,21 @@ const GekanatorPage: FC = () => { className="rounded bg-pink-600 px-4 py-2 font-bold text-white hover:bg-pink-500 disabled:opacity-50" disabled={ + !(canPersistGame) + || extraQuestionState !== 'ready' || extraQuestionAnswersMutation.isPending || extraQuestions.some ( question => !(extraQuestionAnswers[String (question.id)])) } onClick={saveExtraQuestions}> - 学習する + 送信
-
)} - - {phase === 'learned' && ( -
-

{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ.次はもっと見通す.'}

- + {!(canPersistGame) && ( +

+ 未ログインのため追加学習は保存されません。 +

)}
)}