From e57c0e038ea3c36297eaf577a0e701fbcc4084c4 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Wed, 2 Apr 2025 09:28:41 +0100 Subject: [PATCH 01/22] feat: adds bonus for quick answering --- .../game_player_answers_controller.rb | 23 +++- app/models/game_player.rb | 4 + app/models/game_question.rb | 11 ++ .../games/components/_players_status.html.erb | 2 + app/views/games/screens/_start.html.erb | 2 +- ...145708_add_started_at_to_game_questions.rb | 7 ++ db/schema.rb | 3 +- .../game_player_answers_controller_test.rb | 114 ++++++++++++++++++ test/fixtures/game_questions.yml | 1 + test/models/game_player_test.rb | 67 ++++++++++ test/models/game_question_test.rb | 30 +++++ 11 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20250401145708_add_started_at_to_game_questions.rb create mode 100644 test/controllers/game_player_answers_controller_test.rb diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index 6be7632..db6e745 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -16,9 +16,13 @@ def create correct: @answer.correct ) + time_at_answer = Time.current + time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_a).round : 0 + bonus_points = calculate_speed_bonus(@game_question.question.points, time_taken) + if player_answer.save if @answer.correct - @game_player.update(points: @game_player.points + @game_question.question.points) + @game_player.award_points!(@game_question.question.points + bonus_points) end render json: { message: "Answer submitted successfully" }, status: :ok @@ -29,6 +33,23 @@ def create private + def calculate_speed_bonus(question_points, time_taken) + return 0 if time_taken > 8 + + if question_points > 10 + max_bonus = (question_points * 0.3).round + scale = [(8 - time_taken) / 6.0, 1.0].min + (scale * max_bonus).round + else + case time_taken + when 0..2 then 3 + when 2..4 then 2 + when 4..6 then 1 + else 0 + end + end + end + def set_game @game = Game.find(params[:id]) end diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 02a4544..2a89cc5 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -30,4 +30,8 @@ def find_answer_for(game_question) player_answers.find_by(game_question: game_question) end + def award_points!(question_points, bonus_points) + increment!(:points, question_points + bonus_points) + end + end diff --git a/app/models/game_question.rb b/app/models/game_question.rb index c9ac96c..c21b2a8 100644 --- a/app/models/game_question.rb +++ b/app/models/game_question.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null @@ -24,8 +25,18 @@ class GameQuestion < ApplicationRecord belongs_to :question has_many :player_answers, dependent: :destroy + before_save :set_started_at_if_answering + def answers_count player_answers.group(:answer_id).count end + private + + def set_started_at_if_answering + return unless current_phase_changed? && answering? && started_at.nil? + + self.started_at = Time.current + end + end diff --git a/app/views/games/components/_players_status.html.erb b/app/views/games/components/_players_status.html.erb index d1199e2..a24b9a7 100644 --- a/app/views/games/components/_players_status.html.erb +++ b/app/views/games/components/_players_status.html.erb @@ -11,6 +11,8 @@ <% end %> +
+

Players joined (<%= game.players.count %>)

<% else %>

Waiting for players to join...

<% end %> diff --git a/app/views/games/screens/_start.html.erb b/app/views/games/screens/_start.html.erb index eab0dbd..87086fb 100644 --- a/app/views/games/screens/_start.html.erb +++ b/app/views/games/screens/_start.html.erb @@ -4,7 +4,7 @@ <% if host_user? %>

- Invite players by sending them a + Invite players by sending them a <%= link_to root_url + @game.key, data: { controller: "clipboard", action: "click->clipboard#copy", tooltip: "copy" } do %> link to the game <%= image_tag "copy.svg", width: 20, alt: "Copy link" %> diff --git a/db/migrate/20250401145708_add_started_at_to_game_questions.rb b/db/migrate/20250401145708_add_started_at_to_game_questions.rb new file mode 100644 index 0000000..7673ffd --- /dev/null +++ b/db/migrate/20250401145708_add_started_at_to_game_questions.rb @@ -0,0 +1,7 @@ +class AddStartedAtToGameQuestions < ActiveRecord::Migration[8.1] + + def change + add_column :game_questions, :started_at, :datetime + end + +end diff --git a/db/schema.rb b/db/schema.rb index c435b1e..8926f1e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2024_11_23_143824) do +ActiveRecord::Schema[8.1].define(version: 2025_04_01_145708) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -66,6 +66,7 @@ t.integer "current_phase", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.datetime "started_at" t.index ["game_id"], name: "index_game_questions_on_game_id" t.index ["question_id"], name: "index_game_questions_on_question_id" end diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb new file mode 100644 index 0000000..fbd5406 --- /dev/null +++ b/test/controllers/game_player_answers_controller_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "test_helper" + +class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest + + # Helper controller for testing private method + class TestableGamePlayerAnswersController < GamePlayerAnswersController + + public :calculate_speed_bonus + + end + + def setup + @controller = TestableGamePlayerAnswersController.new + end + + # High-point questions (points > 10) – exponential-like scaling + + test "high-point fast answer (0s)" do + bonus = @controller.calculate_speed_bonus(40, 0) + assert_equal 12, bonus # 40 * 0.3 = 12, scale = 1 (max possible cuz quick) -> bonus = 12 + end + + test "high-point exact 2s" do + bonus = @controller.calculate_speed_bonus(40, 2) + # scale = (8 - 2) / 6 = 1.0 → 12 bonus + assert_equal 12, bonus + end + + test "high-point exact 4s" do + bonus = @controller.calculate_speed_bonus(40, 4) + assert_equal 8, bonus + end + + test "high-point exact 6s" do + bonus = @controller.calculate_speed_bonus(40, 6) + assert_equal 4, bonus + end + + test "high-point exact 8s" do + bonus = @controller.calculate_speed_bonus(40, 8) + assert_equal 0, bonus + end + + test "high-point just under 8s" do + bonus = @controller.calculate_speed_bonus(40, 7.9) + assert_equal 0, bonus + end + + test "high-point borderline (10 points) should behave like low-point" do + bonus = @controller.calculate_speed_bonus(10, 1) + assert_equal 3, bonus + end + + test "very high-point question fast answer" do + bonus = @controller.calculate_speed_bonus(1000, 0) + # max_bonus = 300, scale = 8 / 6 = 1..., bonus = 300 + assert_equal 300, bonus + end + + test "high-point negative time (should scale up)" do + bonus = @controller.calculate_speed_bonus(50, -2) + assert_equal 15, bonus + end + + # Low-point questions (<= 10) – fixed tiers + + test "low-point exact 2s" do + bonus = @controller.calculate_speed_bonus(6, 2) + assert_equal 3, bonus + end + + test "low-point exact 4s" do + bonus = @controller.calculate_speed_bonus(6, 4) + assert_equal 2, bonus # falls into 2..4 range (.. is inclusive) + end + + test "low-point edge 6s" do + bonus = @controller.calculate_speed_bonus(6, 6) + assert_equal 1, bonus + end + + test "low-point above 6s" do + bonus = @controller.calculate_speed_bonus(6, 6.1) + assert_equal 0, bonus + end + + test "low-point just under 2s" do + bonus = @controller.calculate_speed_bonus(10, 1.9) + assert_equal 3, bonus + end + + test "low-point negative time (should still max tier)" do + bonus = @controller.calculate_speed_bonus(8, -1) + assert_equal 0, bonus + end + + test "zero-point question" do + bonus = @controller.calculate_speed_bonus(0, 1) + assert_equal 3, bonus # falls under low-point logic + end + + test "negative-point question" do + bonus = @controller.calculate_speed_bonus(-5, 1) + assert_equal 3, bonus # still low-point logic + end + + test "bonus never negative even with high time" do + bonus = @controller.calculate_speed_bonus(100, 100) + assert_equal 0, bonus + end + +end diff --git a/test/fixtures/game_questions.yml b/test/fixtures/game_questions.yml index 68efc62..5fac638 100644 --- a/test/fixtures/game_questions.yml +++ b/test/fixtures/game_questions.yml @@ -10,6 +10,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index 4914b81..cebfcf6 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -74,4 +74,71 @@ def setup assert_nil @game_player.find_answer_for(game_question) end + test "awards zero points when both are zero" do + @game_player.update!(points: 10) + @game_player.award_points!(0, 0) + assert_equal 10, @game_player.reload.points + end + + test "awards only bonus points" do + @game_player.update!(points: 10) + @game_player.award_points!(0, 5) + assert_equal 15, @game_player.reload.points + end + + test "awards only question points" do + @game_player.update!(points: 10) + @game_player.award_points!(20, 0) + assert_equal 30, @game_player.reload.points + end + + test "awards both question and bonus points" do + @game_player.update!(points: 0) + @game_player.award_points!(15, 5) + assert_equal 20, @game_player.reload.points + end + + test "handles large point values" do + @game_player.update!(points: 1_000_000) + @game_player.award_points!(500_000, 250_000) + assert_equal 1_750_000, @game_player.reload.points + end + + test "handles negative bonus points (should subtract)" do + @game_player.update!(points: 20) + @game_player.award_points!(10, -5) + assert_equal 25, @game_player.reload.points + end + + test "handles negative question points (penalty)" do + @game_player.update!(points: 30) + @game_player.award_points!(-10, 0) + assert_equal 20, @game_player.reload.points + end + + test "handles both points being negative" do + @game_player.update!(points: 100) + @game_player.award_points!(-30, -10) + assert_equal 60, @game_player.reload.points + end + + test "multiple awards accumulate correctly" do + @game_player.update!(points: 0) + 5.times { @game_player.award_points!(10, 2) } + assert_equal 60, @game_player.reload.points + end + + test "can award points in quick succession" do + @game_player.update!(points: 0) + + threads = 10.times.map do + Thread.new do + GamePlayer.find(@game_player.id).award_points!(5, 1) + end + end + threads.each(&:join) + + assert_equal 60, @game_player.reload.points + end + end diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index 7732681..cd57e78 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # current_phase :integer default("idle"), not null +# started_at :datetime # created_at :datetime not null # updated_at :datetime not null # game_id :integer not null @@ -69,4 +70,33 @@ def setup assert_equal 1, counts[player_answer.answer_id] end + test "sets started_at when changing to answering and it's nil" do + assert_nil @game_question.started_at + + @game_question.current_phase = "answering" + freeze_time do + now = Time.current + @game_question.save! + + assert_equal now, @game_question.started_at + end + end + + test "does not set started_at if already set" do + @game_question.update!(started_at: 2.minutes.ago, current_phase: "reading") + + @game_question.current_phase = "answering" + @game_question.save! + + # Should not overwrite started_at + assert_in_delta 2.minutes.ago.to_i, @game_question.started_at.to_i, 1 + end + + test "does not set started_at if phase is not changing to answering" do + @game_question.current_phase = "reading" # not transitioning to 'answering' + @game_question.save! + + assert_nil @game_question.started_at + end + end From 7f2d5ef25b31c6cdabc168db77e81ad5712201cb Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Wed, 2 Apr 2025 12:01:55 +0100 Subject: [PATCH 02/22] chore: rework some of it --- .../game_player_answers_controller.rb | 22 +- app/models/game_player.rb | 46 +++- .../game_player_answers_controller_test.rb | 114 --------- test/fixtures/game_questions.yml | 18 +- test/fixtures/questions.yml | 7 +- test/models/game_player_test.rb | 239 +++++++++++++++--- 6 files changed, 267 insertions(+), 179 deletions(-) delete mode 100644 test/controllers/game_player_answers_controller_test.rb diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index db6e745..e1c6772 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -17,12 +17,11 @@ def create ) time_at_answer = Time.current - time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_a).round : 0 - bonus_points = calculate_speed_bonus(@game_question.question.points, time_taken) + time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : 0 if player_answer.save if @answer.correct - @game_player.award_points!(@game_question.question.points + bonus_points) + @game_player.award_points!(@game_question.question, time_taken) end render json: { message: "Answer submitted successfully" }, status: :ok @@ -33,23 +32,6 @@ def create private - def calculate_speed_bonus(question_points, time_taken) - return 0 if time_taken > 8 - - if question_points > 10 - max_bonus = (question_points * 0.3).round - scale = [(8 - time_taken) / 6.0, 1.0].min - (scale * max_bonus).round - else - case time_taken - when 0..2 then 3 - when 2..4 then 2 - when 4..6 then 1 - else 0 - end - end - end - def set_game @game = Game.find(params[:id]) end diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 2a89cc5..3283d47 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -26,12 +26,56 @@ class GamePlayer < ApplicationRecord validates :points, numericality: { greater_than_or_equal_to: 0 } + # @param [GameQuestion] + # @return [PlayerAnswer, nil] def find_answer_for(game_question) player_answers.find_by(game_question: game_question) end - def award_points!(question_points, bonus_points) + # Awards points to the game player by incrementing their total points with + # the sum of the current question points with all the bonuses they can get + # + # @param [GameQuestion] game_question + # @param [Integer] time_taken + # @return [GamePlayer] + def award_points!(game_question, time_taken) + question_points = game_question.question.points + bonus_points = calculate_speed_bonus(question_points, time_taken) + increment!(:points, question_points + bonus_points) end + private + + # Calculates the bonus points a player earns for answering quickly. + # + # For high-value questions (> 10 points), the bonus is a percentage (30%) + # of the question's value, scaled linearly based on how fast the answer was submitted. + # Full bonus is awarded if answered in 2 seconds or less, and the bonus decreases + # to zero by 8 seconds. Bonus is rounded to the nearest integer. + # + # For low-value questions (<= 10 points), a simple tiered bonus is applied + # + # time_taken is always expected to be >= 0 + # + # @param [Integer] question_points + # @param [Integer] time_taken + # @return [Integer] + def calculate_speed_bonus(question_points, time_taken) + return 0 if time_taken > 8 + + if question_points > 10 + max_bonus = (question_points * 0.3).round + scale = [(8 - time_taken) / 6.0, 1.0].min + (scale * max_bonus).round + else + case time_taken + when 0..2 then 3 + when 2..4 then 2 + when 4..6 then 1 + else 0 + end + end + end + end diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb deleted file mode 100644 index fbd5406..0000000 --- a/test/controllers/game_player_answers_controller_test.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest - - # Helper controller for testing private method - class TestableGamePlayerAnswersController < GamePlayerAnswersController - - public :calculate_speed_bonus - - end - - def setup - @controller = TestableGamePlayerAnswersController.new - end - - # High-point questions (points > 10) – exponential-like scaling - - test "high-point fast answer (0s)" do - bonus = @controller.calculate_speed_bonus(40, 0) - assert_equal 12, bonus # 40 * 0.3 = 12, scale = 1 (max possible cuz quick) -> bonus = 12 - end - - test "high-point exact 2s" do - bonus = @controller.calculate_speed_bonus(40, 2) - # scale = (8 - 2) / 6 = 1.0 → 12 bonus - assert_equal 12, bonus - end - - test "high-point exact 4s" do - bonus = @controller.calculate_speed_bonus(40, 4) - assert_equal 8, bonus - end - - test "high-point exact 6s" do - bonus = @controller.calculate_speed_bonus(40, 6) - assert_equal 4, bonus - end - - test "high-point exact 8s" do - bonus = @controller.calculate_speed_bonus(40, 8) - assert_equal 0, bonus - end - - test "high-point just under 8s" do - bonus = @controller.calculate_speed_bonus(40, 7.9) - assert_equal 0, bonus - end - - test "high-point borderline (10 points) should behave like low-point" do - bonus = @controller.calculate_speed_bonus(10, 1) - assert_equal 3, bonus - end - - test "very high-point question fast answer" do - bonus = @controller.calculate_speed_bonus(1000, 0) - # max_bonus = 300, scale = 8 / 6 = 1..., bonus = 300 - assert_equal 300, bonus - end - - test "high-point negative time (should scale up)" do - bonus = @controller.calculate_speed_bonus(50, -2) - assert_equal 15, bonus - end - - # Low-point questions (<= 10) – fixed tiers - - test "low-point exact 2s" do - bonus = @controller.calculate_speed_bonus(6, 2) - assert_equal 3, bonus - end - - test "low-point exact 4s" do - bonus = @controller.calculate_speed_bonus(6, 4) - assert_equal 2, bonus # falls into 2..4 range (.. is inclusive) - end - - test "low-point edge 6s" do - bonus = @controller.calculate_speed_bonus(6, 6) - assert_equal 1, bonus - end - - test "low-point above 6s" do - bonus = @controller.calculate_speed_bonus(6, 6.1) - assert_equal 0, bonus - end - - test "low-point just under 2s" do - bonus = @controller.calculate_speed_bonus(10, 1.9) - assert_equal 3, bonus - end - - test "low-point negative time (should still max tier)" do - bonus = @controller.calculate_speed_bonus(8, -1) - assert_equal 0, bonus - end - - test "zero-point question" do - bonus = @controller.calculate_speed_bonus(0, 1) - assert_equal 3, bonus # falls under low-point logic - end - - test "negative-point question" do - bonus = @controller.calculate_speed_bonus(-5, 1) - assert_equal 3, bonus # still low-point logic - end - - test "bonus never negative even with high time" do - bonus = @controller.calculate_speed_bonus(100, 100) - assert_equal 0, bonus - end - -end diff --git a/test/fixtures/game_questions.yml b/test/fixtures/game_questions.yml index 5fac638..18d2f9d 100644 --- a/test/fixtures/game_questions.yml +++ b/test/fixtures/game_questions.yml @@ -23,22 +23,32 @@ # one: game: one - question: one + question: one current_phase: idle two: game: one - question: two + question: two current_phase: idle first_in_one: id: 1 game: one - question: one + question: one current_phase: idle second_in_one: id: 2 game: one - question: two + question: two current_phase: idle + +basic: + game: one + question: one + current_phase: answering + +high_value: + game: one + question: four + current_phase: answering diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml index 6e2fc1d..43a1ec3 100644 --- a/test/fixtures/questions.yml +++ b/test/fixtures/questions.yml @@ -25,10 +25,15 @@ one: two: quiz: two - text: Question Two + text: Question Two points: 20 three: quiz: one text: Question Three points: 30 + +four: + quiz: two + text: Question Four + points: 40 diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index cebfcf6..b9b3cee 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -23,9 +23,13 @@ class GamePlayerTest < ActiveSupport::TestCase def setup @game_player = game_players(:one) # Assumes fixtures are set up @game_question = game_questions(:one) + + @basic_question = game_questions(:basic) + @high_value_question = game_questions(:high_value) end # Associations + test "should belong to a game" do assert_respond_to @game_player, :game assert_not_nil @game_player.game @@ -41,6 +45,7 @@ def setup end # Validations + test "points should be non-negative" do @game_player.points = -1 assert_not @game_player.valid? @@ -53,6 +58,7 @@ def setup end # Scope + test "by_points scope should order by points in descending order" do player1 = game_players(:one) player2 = game_players(:two) @@ -64,6 +70,7 @@ def setup end # Instance Methods + test "find_answer_for should return the correct player answer" do player_answer = player_answers(:one) assert_equal player_answer, @game_player.find_answer_for(@game_question) @@ -76,69 +83,223 @@ def setup test "awards zero points when both are zero" do @game_player.update!(points: 10) - @game_player.award_points!(0, 0) - assert_equal 10, @game_player.reload.points - end - - test "awards only bonus points" do - @game_player.update!(points: 10) - @game_player.award_points!(0, 5) - assert_equal 15, @game_player.reload.points + @basic_question.question.update!(points: 1) # edge case + @game_player.award_points!(@basic_question, 30) # too late = 0 bonus + assert_equal 11, @game_player.reload.points end test "awards only question points" do @game_player.update!(points: 10) - @game_player.award_points!(20, 0) + @basic_question.question.update!(points: 20) + @game_player.award_points!(@basic_question, 100) # too late = 0 bonus assert_equal 30, @game_player.reload.points end - test "awards both question and bonus points" do + test "awards question and bonus points" do + @game_player.update!(points: 0) + @basic_question.question.update!(points: 20) + @game_player.award_points!(@basic_question, 0) # max bonus + expected = 20 + 6 # 20 * 0.3 = 6 → full scale = 6 + assert_equal expected, @game_player.reload.points + end + + test "awards max bonus for high-value question answered fast" do + @game_player.update!(points: 0) + @high_value_question.question.update!(points: 100) + @game_player.award_points!(@high_value_question, 2) + expected = 100 + 30 # 100 * 0.3 = 30, full bonus at 2s + assert_equal expected, @game_player.reload.points + end + + test "handles bonus cutoff at 8 seconds" do + @game_player.update!(points: 0) + @high_value_question.question.update!(points: 100) + + @game_player.award_points!(@high_value_question, 8) # bonus cutoff + assert_equal 100, @game_player.reload.points + end + + test "handles multiple awards correctly" do + @game_player.update!(points: 0) + @basic_question.question.update!(points: 10) + + 3.times { @game_player.award_points!(@basic_question, 2) } + # 10 + 3 (bonus) = 13 * 3 = 39 + assert_equal 39, @game_player.reload.points + end + + test "awards minimum legal question points with no bonus" do + @game_player.update!(points: 5) + @basic_question.question.update!(points: 1) + + @game_player.award_points!(@basic_question, 10) # way too late + assert_equal 6, @game_player.reload.points + end + + test "awards minimum legal question points with max bonus" do @game_player.update!(points: 0) - @game_player.award_points!(15, 5) - assert_equal 20, @game_player.reload.points + @basic_question.question.update!(points: 1) + + @game_player.award_points!(@basic_question, 1) # fast answer + # low-point logic: bonus = 3 + assert_equal 4, @game_player.reload.points end - test "handles large point values" do - @game_player.update!(points: 1_000_000) - @game_player.award_points!(500_000, 250_000) - assert_equal 1_750_000, @game_player.reload.points + test "awards max bonus at edge boundary time 2s" do + @game_player.update!(points: 0) + @high_value_question.question.update!(points: 100) + + @game_player.award_points!(@high_value_question, 2) + # still considered full scale bonus + assert_equal 130, @game_player.reload.points end - test "handles negative bonus points (should subtract)" do - @game_player.update!(points: 20) - @game_player.award_points!(10, -5) - assert_equal 25, @game_player.reload.points + test "awards partial bonus at midpoint time 5s" do + @game_player.update!(points: 0) + @high_value_question.question.update!(points: 100) + + @game_player.award_points!(@high_value_question, 5) + # scale = (8 - 5) / 6 = 0.5 → bonus = 15 + assert_equal 115, @game_player.reload.points end - test "handles negative question points (penalty)" do - @game_player.update!(points: 30) - @game_player.award_points!(-10, 0) - assert_equal 20, @game_player.reload.points + test "awards 1 bonus point for low-value question at 5s" do + @game_player.update!(points: 0) + @basic_question.question.update!(points: 10) + + @game_player.award_points!(@basic_question, 5) + # falls into 4..6 range → bonus = 1 + assert_equal 11, @game_player.reload.points end - test "handles both points being negative" do - @game_player.update!(points: 100) - @game_player.award_points!(-30, -10) - assert_equal 60, @game_player.reload.points + test "no bonus exactly at cutoff time 8s" do + @game_player.update!(points: 0) + @basic_question.question.update!(points: 10) + + @game_player.award_points!(@basic_question, 8) + assert_equal 10, @game_player.reload.points end - test "multiple awards accumulate correctly" do + test "clamps negative time_taken to zero bonus" do @game_player.update!(points: 0) - 5.times { @game_player.award_points!(10, 2) } - assert_equal 60, @game_player.reload.points + @basic_question.question.update!(points: 10) + + @game_player.award_points!(@basic_question, -3) + # time_taken is negative, but logic returns 0 bonus + assert_equal 10, @game_player.reload.points end - test "can award points in quick succession" do + test "awards correct bonus just under 4s (3s => 2 bonus)" do @game_player.update!(points: 0) + @basic_question.question.update!(points: 10) + + @game_player.award_points!(@basic_question, 3) + assert_equal 12, @game_player.reload.points + end - threads = 10.times.map do - Thread.new do - GamePlayer.find(@game_player.id).award_points!(5, 1) - end - end - threads.each(&:join) + test "awards correct bonus just under 6s (5s => 1 bonus)" do + @game_player.update!(points: 0) + @basic_question.question.update!(points: 10) + + @game_player.award_points!(@basic_question, 5) + assert_equal 11, @game_player.reload.points + end + + # Private methods + + # High-point questions (points > 10) – exponential-like scaling + test "high-point fast answer (0s)" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 0) + assert_equal 12, bonus # 40 * 0.3 = 12, scale = 1 (max possible cuz quick) -> bonus = 12 + end + + test "high-point exact 2s" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 2) + # scale = (8 - 2) / 6 = 1.0 → 12 bonus + assert_equal 12, bonus + end + + test "high-point exact 4s" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 4) + assert_equal 8, bonus + end + + test "high-point exact 6s" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 6) + assert_equal 4, bonus + end + + test "high-point exact 8s" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 8) + assert_equal 0, bonus + end + + test "high-point just under 8s" do + bonus = @game_player.send(:calculate_speed_bonus, 40, 7.9) + assert_equal 0, bonus + end + + test "high-point borderline (10 points) should behave like low-point" do + bonus = @game_player.send(:calculate_speed_bonus, 10, 1) + assert_equal 3, bonus + end + + test "very high-point question fast answer" do + bonus = @game_player.send(:calculate_speed_bonus, 1000, 0) + # max_bonus = 300, scale = 8 / 6 = 1..., bonus = 300 + assert_equal 300, bonus + end + + test "high-point negative time (should scale up)" do + bonus = @game_player.send(:calculate_speed_bonus, 50, -2) + assert_equal 15, bonus + end + + # Low-point questions (<= 10) – fixed tiers + + test "low-point exact 2s" do + bonus = @game_player.send(:calculate_speed_bonus, 6, 2) + assert_equal 3, bonus + end + + test "low-point exact 4s" do + bonus = @game_player.send(:calculate_speed_bonus, 6, 4) + assert_equal 2, bonus # falls into 2..4 range (.. is inclusive) + end + + test "low-point edge 6s" do + bonus = @game_player.send(:calculate_speed_bonus, 6, 6) + assert_equal 1, bonus + end + + test "low-point above 6s" do + bonus = @game_player.send(:calculate_speed_bonus, 6, 6.1) + assert_equal 0, bonus + end + + test "low-point just under 2s" do + bonus = @game_player.send(:calculate_speed_bonus, 10, 1.9) + assert_equal 3, bonus + end + + test "low-point negative time (should still max tier)" do + bonus = @game_player.send(:calculate_speed_bonus, 8, -1) + assert_equal 0, bonus + end + + test "zero-point question" do + bonus = @game_player.send(:calculate_speed_bonus, 0, 1) + assert_equal 3, bonus # falls under low-point logic + end + + test "negative-point question" do + bonus = @game_player.send(:calculate_speed_bonus, -5, 1) + assert_equal 3, bonus # still low-point logic + end - assert_equal 60, @game_player.reload.points + test "bonus never negative even with high time" do + bonus = @game_player.send(:calculate_speed_bonus, 100, 100) + assert_equal 0, bonus end end From 58c59c0f2dfeb63050caefd3239f7f683cc8a162 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Wed, 2 Apr 2025 12:14:33 +0100 Subject: [PATCH 03/22] chore: rename --- app/models/game_player.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 3283d47..25261ca 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -40,9 +40,9 @@ def find_answer_for(game_question) # @return [GamePlayer] def award_points!(game_question, time_taken) question_points = game_question.question.points - bonus_points = calculate_speed_bonus(question_points, time_taken) + speed_bonus = calculate_speed_bonus(question_points, time_taken) - increment!(:points, question_points + bonus_points) + increment!(:points, question_points + speed_bonus) end private From 1a38e1d9314841678a87981c535300b78b5d7b49 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Thu, 3 Apr 2025 19:21:18 +0100 Subject: [PATCH 04/22] feat: also add streak bonuses --- .../game_player_answers_controller.rb | 2 +- app/models/game_player.rb | 48 +++++-- ...1237_add_current_streak_to_game_players.rb | 7 + db/schema.rb | 3 +- test/fixtures/game_players.yml | 13 +- test/fixtures/questions.yml | 5 - test/models/game_player_test.rb | 134 +++++++++++------- 7 files changed, 137 insertions(+), 75 deletions(-) create mode 100644 db/migrate/20250403171237_add_current_streak_to_game_players.rb diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index e1c6772..0be772d 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -21,7 +21,7 @@ def create if player_answer.save if @answer.correct - @game_player.award_points!(@game_question.question, time_taken) + @game_player.award_points!(@game_question.question.points, time_taken) end render json: { message: "Answer submitted successfully" }, status: :ok diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 25261ca..24ae92c 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # @@ -35,14 +36,19 @@ def find_answer_for(game_question) # Awards points to the game player by incrementing their total points with # the sum of the current question points with all the bonuses they can get # - # @param [GameQuestion] game_question + # @param [Integer] question_points # @param [Integer] time_taken # @return [GamePlayer] - def award_points!(game_question, time_taken) - question_points = game_question.question.points + def award_points!(question_points, time_taken) speed_bonus = calculate_speed_bonus(question_points, time_taken) + streak_bonus = calculate_streak_bonus(question_points) - increment!(:points, question_points + speed_bonus) + with_lock do + update_columns( + points: points + question_points + speed_bonus + streak_bonus, + current_streak: streak_length + ) + end end private @@ -78,4 +84,26 @@ def calculate_speed_bonus(question_points, time_taken) end end + # Calculates streak bonus based on the player's current + # streak length, the bonus is calculated by + # multiplying the question points with the streak length + # and the bonus multiplier which is 25% + # + # @param [Integer] question_points + # @return [Integer] + def calculate_streak_bonus(question_points) + return 0 if streak_length == 1 + + (question_points * streak_length * 0.25).round + end + + # @return [Integer] + def streak_length + @streak_length ||= begin + recent_answers = player_answers.order(created_at: :desc).pluck(:correct) + streak = recent_answers.take_while { |correct| correct }.count + [streak, 1].max + end + end + end diff --git a/db/migrate/20250403171237_add_current_streak_to_game_players.rb b/db/migrate/20250403171237_add_current_streak_to_game_players.rb new file mode 100644 index 0000000..c2ebed9 --- /dev/null +++ b/db/migrate/20250403171237_add_current_streak_to_game_players.rb @@ -0,0 +1,7 @@ +class AddCurrentStreakToGamePlayers < ActiveRecord::Migration[8.1] + + def change + add_column :game_players, :current_streak, :integer + end + +end diff --git a/db/schema.rb b/db/schema.rb index 8926f1e..46be422 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_04_01_145708) do +ActiveRecord::Schema[8.1].define(version: 2025_04_03_171237) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -56,6 +56,7 @@ t.integer "points", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "current_streak" t.index ["game_id"], name: "index_game_players_on_game_id" t.index ["user_id"], name: "index_game_players_on_user_id" end diff --git a/test/fixtures/game_players.yml b/test/fixtures/game_players.yml index 7a8a110..4ad8724 100644 --- a/test/fixtures/game_players.yml +++ b/test/fixtures/game_players.yml @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml index 43a1ec3..87d5b75 100644 --- a/test/fixtures/questions.yml +++ b/test/fixtures/questions.yml @@ -32,8 +32,3 @@ three: quiz: one text: Question Three points: 30 - -four: - quiz: two - text: Question Four - points: 40 diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index b9b3cee..7febe5f 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -4,12 +4,13 @@ # # Table name: game_players # -# id :integer not null, primary key -# points :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# game_id :integer not null -# user_id :integer not null +# id :integer not null, primary key +# current_streak :integer +# points :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# game_id :integer not null +# user_id :integer not null # # Indexes # @@ -23,9 +24,6 @@ class GamePlayerTest < ActiveSupport::TestCase def setup @game_player = game_players(:one) # Assumes fixtures are set up @game_question = game_questions(:one) - - @basic_question = game_questions(:basic) - @high_value_question = game_questions(:high_value) end # Associations @@ -69,8 +67,8 @@ def setup assert_equal [player2, player1], result end - # Instance Methods - + ############ Instance Methods + # find_answer_for test "find_answer_for should return the correct player answer" do player_answer = player_answers(:one) assert_equal player_answer, @game_player.find_answer_for(@game_question) @@ -81,132 +79,109 @@ def setup assert_nil @game_player.find_answer_for(game_question) end + # award_points test "awards zero points when both are zero" do @game_player.update!(points: 10) - @basic_question.question.update!(points: 1) # edge case - @game_player.award_points!(@basic_question, 30) # too late = 0 bonus + @game_player.award_points!(1, 30) # too late = 0 bonus assert_equal 11, @game_player.reload.points end test "awards only question points" do @game_player.update!(points: 10) - @basic_question.question.update!(points: 20) - @game_player.award_points!(@basic_question, 100) # too late = 0 bonus + @game_player.award_points!(20, 100) # too late = 0 bonus assert_equal 30, @game_player.reload.points end test "awards question and bonus points" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 20) - @game_player.award_points!(@basic_question, 0) # max bonus + @game_player.award_points!(20, 0) # max bonus expected = 20 + 6 # 20 * 0.3 = 6 → full scale = 6 assert_equal expected, @game_player.reload.points end test "awards max bonus for high-value question answered fast" do @game_player.update!(points: 0) - @high_value_question.question.update!(points: 100) - @game_player.award_points!(@high_value_question, 2) + @game_player.award_points!(100, 2) expected = 100 + 30 # 100 * 0.3 = 30, full bonus at 2s assert_equal expected, @game_player.reload.points end test "handles bonus cutoff at 8 seconds" do @game_player.update!(points: 0) - @high_value_question.question.update!(points: 100) - - @game_player.award_points!(@high_value_question, 8) # bonus cutoff + @game_player.award_points!(100, 8) # bonus cutoff assert_equal 100, @game_player.reload.points end test "handles multiple awards correctly" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - 3.times { @game_player.award_points!(@basic_question, 2) } + 3.times { @game_player.award_points!(10, 2) } # 10 + 3 (bonus) = 13 * 3 = 39 assert_equal 39, @game_player.reload.points end test "awards minimum legal question points with no bonus" do @game_player.update!(points: 5) - @basic_question.question.update!(points: 1) - - @game_player.award_points!(@basic_question, 10) # way too late + @game_player.award_points!(1, 10) # way too late assert_equal 6, @game_player.reload.points end test "awards minimum legal question points with max bonus" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 1) - - @game_player.award_points!(@basic_question, 1) # fast answer + @game_player.award_points!(1, 1) # fast answer # low-point logic: bonus = 3 assert_equal 4, @game_player.reload.points end test "awards max bonus at edge boundary time 2s" do @game_player.update!(points: 0) - @high_value_question.question.update!(points: 100) - - @game_player.award_points!(@high_value_question, 2) + @game_player.award_points!(100, 2) # still considered full scale bonus assert_equal 130, @game_player.reload.points end test "awards partial bonus at midpoint time 5s" do @game_player.update!(points: 0) - @high_value_question.question.update!(points: 100) - - @game_player.award_points!(@high_value_question, 5) + @game_player.award_points!(100, 5) # scale = (8 - 5) / 6 = 0.5 → bonus = 15 assert_equal 115, @game_player.reload.points end test "awards 1 bonus point for low-value question at 5s" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - @game_player.award_points!(@basic_question, 5) + @game_player.award_points!(10, 5) # falls into 4..6 range → bonus = 1 assert_equal 11, @game_player.reload.points end test "no bonus exactly at cutoff time 8s" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - @game_player.award_points!(@basic_question, 8) + @game_player.award_points!(10, 8) assert_equal 10, @game_player.reload.points end test "clamps negative time_taken to zero bonus" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - @game_player.award_points!(@basic_question, -3) + @game_player.award_points!(10, -3) # time_taken is negative, but logic returns 0 bonus assert_equal 10, @game_player.reload.points end test "awards correct bonus just under 4s (3s => 2 bonus)" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - @game_player.award_points!(@basic_question, 3) + @game_player.award_points!(10, 3) assert_equal 12, @game_player.reload.points end test "awards correct bonus just under 6s (5s => 1 bonus)" do @game_player.update!(points: 0) - @basic_question.question.update!(points: 10) - - @game_player.award_points!(@basic_question, 5) + @game_player.award_points!(10, 5) assert_equal 11, @game_player.reload.points end # Private methods + # calculate_speed_bonus + # High-point questions (points > 10) – exponential-like scaling test "high-point fast answer (0s)" do bonus = @game_player.send(:calculate_speed_bonus, 40, 0) @@ -302,4 +277,59 @@ def setup assert_equal 0, bonus end + # calculate_streak_bonus + + test "calculate_streak_bonus returns 0 when streak is 1" do + @game_player.stub :streak_length, 1 do + result = @game_player.send(:calculate_streak_bonus, 50) + assert_equal 0, result + end + end + + test "calculate_streak_bonus returns correct bonus when streak is > 1" do + @game_player.stub :streak_length, 4 do + result = @game_player.send(:calculate_streak_bonus, 40) + # 40 * 4 * 0.25 = 40 + assert_equal 40, result + end + end + + test "calculate_streak_bonus rounds correctly" do + @game_player.stub :streak_length, 3 do + result = @game_player.send(:calculate_streak_bonus, 25) + # 25 * 3 * 0.25 = 18.75 → 19 + assert_equal 19, result + end + end + + # streak_length + + test "streak_length returns 1 if no correct answers" do + @game_player.stub :player_answers, stub(order: stub(pluck: [false, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 1, result + end + end + + test "streak_length returns correct streak count - 3" do + @game_player.stub :player_answers, stub(order: stub(pluck: [true, true, true, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 3, result + end + end + + test "streak_length returns correct streak count - 2" do + @game_player.stub :player_answers, stub(order: stub(pluck: [true, true, false, true])) do + result = @game_player.send(:streak_length) + assert_equal 2, result + end + end + + test "streak_length minimum is 1 even if all are wrong" do + @game_player.stub :player_answers, stub(order: stub(pluck: [false, false, false])) do + result = @game_player.send(:streak_length) + assert_equal 1, result + end + end + end From d0afa603cd1c339dd7eea231f6013f27ea4471c8 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Thu, 3 Apr 2025 20:01:09 +0100 Subject: [PATCH 05/22] chore: remove unused fixtures --- test/fixtures/game_questions.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/fixtures/game_questions.yml b/test/fixtures/game_questions.yml index 18d2f9d..a59e997 100644 --- a/test/fixtures/game_questions.yml +++ b/test/fixtures/game_questions.yml @@ -42,13 +42,3 @@ second_in_one: game: one question: two current_phase: idle - -basic: - game: one - question: one - current_phase: answering - -high_value: - game: one - question: four - current_phase: answering From ad8fdc15d785905934bce104d25c61669598d401 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Thu, 3 Apr 2025 20:16:32 +0100 Subject: [PATCH 06/22] chore: update brakeman --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a8db37e..c91a582 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.2.2) + brakeman (7.0.0) racc builder (3.3.0) capybara (3.40.0) From f75f7f4e6e5bc5945d608df0ea74d67384e12ca3 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Mon, 7 Apr 2025 22:06:52 +0100 Subject: [PATCH 07/22] chore: rubocop --- Gemfile.lock | 2 +- test/models/quiz_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c91a582..ec3e0f4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (7.0.0) + brakeman (7.0.2) racc builder (3.3.0) capybara (3.40.0) diff --git a/test/models/quiz_test.rb b/test/models/quiz_test.rb index 13d6deb..96d573b 100644 --- a/test/models/quiz_test.rb +++ b/test/models/quiz_test.rb @@ -35,7 +35,7 @@ def setup assert_not @quiz.valid? end - test "playable? should return true when quiz has questions, all have at least 2 answers, and all have correct answers" do + test "playable? should return true when quiz has questions, all have at least 2 answers, and all have correct answers" do # rubocop:disable Layout/LineLength @quiz = Quiz.new( title: "Example quiz 1", user: users(:one) From 1382218c61758bab7eecf506d0d68e79dea8aa60 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Mon, 7 Apr 2025 23:24:22 +0100 Subject: [PATCH 08/22] chore: streak text and styling changes --- app/assets/stylesheets/scoreboard.css | 9 ++- app/assets/stylesheets/streaks.css | 65 +++++++++++++++++++ .../game_player_answers_controller.rb | 2 + .../games/components/_scoreboard.html.erb | 6 ++ app/views/layouts/_head.html.erb | 1 + 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 app/assets/stylesheets/streaks.css diff --git a/app/assets/stylesheets/scoreboard.css b/app/assets/stylesheets/scoreboard.css index 0522e4d..f684055 100644 --- a/app/assets/stylesheets/scoreboard.css +++ b/app/assets/stylesheets/scoreboard.css @@ -1,6 +1,5 @@ .scoreboard-container { max-height: calc(100vh - 4rem); - overflow-y: auto; margin-top: 5rem; } @@ -36,6 +35,12 @@ font-weight: bold; } +.scoreboard li.losers { + color: #999; + pointer-events: none; + opacity: 0.6; +} + .scoreboard-player-info { display: flex; align-items: center; @@ -48,7 +53,7 @@ height: 40px; border-radius: 50%; object-fit: cover; - border-radius: var(--pico-border-radius, 2rem); + border-radius: var(--pico-border-radius, 2rem); } .scoreboard-player-name { diff --git a/app/assets/stylesheets/streaks.css b/app/assets/stylesheets/streaks.css new file mode 100644 index 0000000..9db6428 --- /dev/null +++ b/app/assets/stylesheets/streaks.css @@ -0,0 +1,65 @@ +:root { + --game-font: "Press Start 2P", cursive; + --sassy: #bf4080; + --coral: #fe5f55; + --royal: #6f2ed8; + --tiffany: #00b4d8; + --peach: #e6f14a; + --off-white: #fffff7; +} + +.wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + margin: auto; + text-align: center; +} + +.neon-text { + position: relative; + margin: 0; + font-family: var(--game-font); + font-size: 1rem; + width: 100%; + text-transform: uppercase; + -webkit-text-stroke: 1px var(--sassy); + color: var(--off-white); + z-index: 10; +} + +.neon-text::before { + content: attr(data-text); + position: absolute; + bottom: 8px; + right: 8px; + font-family: inherit; + font-size: 1rem; + color: var(--sassy); + width: 100%; + height: 100%; + animation: animateTextColor 3s infinite linear; + z-index: -1; +} + +@keyframes animateTextColor { + 0% { + color: var(--tiffany); + } + 20% { + color: var(--coral); + } + 50% { + color: var(--royal); + } + 60% { + color: var(--peach); + } + 80% { + color: var(--sassy); + } + 100% { + color: var(--tiffany); + } +} diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index 0be772d..7089f35 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -22,6 +22,8 @@ def create if player_answer.save if @answer.correct @game_player.award_points!(@game_question.question.points, time_taken) + else + @game_player.update(current_streak: 0) end render json: { message: "Answer submitted successfully" }, status: :ok diff --git a/app/views/games/components/_scoreboard.html.erb b/app/views/games/components/_scoreboard.html.erb index d614ff4..68cbd1d 100644 --- a/app/views/games/components/_scoreboard.html.erb +++ b/app/views/games/components/_scoreboard.html.erb @@ -9,6 +9,12 @@
<%= image_tag player.user.image, alt: player.user.name, width: 40, class: "scoreboard-players-avatar" %> <%= player.user.name %> + <% if player.current_streak > 1 && game.ended_at.nil? %> +
+ <% streak_text = "Streak of #{player.current_streak}!" %> +

<%= streak_text %>

+
+ <% end %>
<%= player.points %> pts diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index 03c5e81..600d331 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -11,6 +11,7 @@ + <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> From b7e34637a15abb4221a2200b3bd2baab9600974f Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:06:27 +0100 Subject: [PATCH 09/22] chore: controller test --- .../game_player_answers_controller_test.rb | 67 +++++++++++++++++++ test/fixtures/answers.yml | 12 +++- test/models/answer_test.rb | 4 +- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 test/controllers/game_player_answers_controller_test.rb diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb new file mode 100644 index 0000000..60a7f27 --- /dev/null +++ b/test/controllers/game_player_answers_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest + + setup do + @user = users(:one) + @game = games(:one) + @game_question = game_questions(:one) + @correct_answer = answers(:correct) + @incorrect_answer = answers(:incorrect) + @game_player = game_players(:one) + + set_current_user(@user) + end + + test "should create player answer with correct answer" do + @game_question.update(started_at: 10.seconds.ago) + + assert_difference("PlayerAnswer.count") do + post player_answer_game_path(@game), params: { + game_question_id: @game_question.id, + selected_answer_id: @correct_answer.id + } + end + + assert_response :success + response_json = JSON.parse(response.body) + assert_equal "Answer submitted successfully", response_json["message"] + + @game_player.reload + assert @game_player.points > 0, "Player should receive points for correct answer" + end + + test "should create player answer with incorrect answer" do + @game_question.update(started_at: 10.seconds.ago) + + @game_player.update(current_streak: 5) + + assert_difference("PlayerAnswer.count") do + post player_answer_game_path(@game), params: { + game_question_id: @game_question.id, + selected_answer_id: @incorrect_answer.id + } + end + + assert_response :success + response_json = JSON.parse(response.body) + assert_equal "Answer submitted successfully", response_json["message"] + + @game_player.reload + assert_equal 0, @game_player.current_streak, "Player streak should reset to 0 after incorrect answer" + end + + test "should handle answer when game_question has no started_at time" do + @game_question.update(started_at: nil) + + assert_difference("PlayerAnswer.count") do + post player_answer_game_path(@game), params: { + game_question_id: @game_question.id, + selected_answer_id: @correct_answer.id + } + end + + assert_response :success + end + +end diff --git a/test/fixtures/answers.yml b/test/fixtures/answers.yml index 2ca58a6..2eefdab 100644 --- a/test/fixtures/answers.yml +++ b/test/fixtures/answers.yml @@ -20,7 +20,7 @@ one: question: one - text: Answer One + text: Answer One correct: true two: @@ -32,3 +32,13 @@ three: question: three text: Answer Three correct: false + +correct: + question: one + text: Correct Answer + correct: true + +incorrect: + question: one + text: Incorrect answer + correct: false diff --git a/test/models/answer_test.rb b/test/models/answer_test.rb index 5cd3595..f6a5672 100644 --- a/test/models/answer_test.rb +++ b/test/models/answer_test.rb @@ -48,7 +48,9 @@ def setup test "should not allow more than MAX_ANSWERS answers for a question" do question = questions(:one) - 5.times { question.answers.create!(text: "Sample answer") } + question.answers.delete_all + + 6.times { question.answers.create!(text: "Sample answer") } extra_answer = question.answers.build(text: "Extra answer") assert_not extra_answer.valid?, "Answer should be invalid as it exceeds the answer limit" From 4ef07ed0839430e51e9f08310ca908cf081cdbe2 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:10:33 +0100 Subject: [PATCH 10/22] chore: rubocop --- test/controllers/game_player_answers_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb index 60a7f27..286c14b 100644 --- a/test/controllers/game_player_answers_controller_test.rb +++ b/test/controllers/game_player_answers_controller_test.rb @@ -28,7 +28,7 @@ class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest assert_equal "Answer submitted successfully", response_json["message"] @game_player.reload - assert @game_player.points > 0, "Player should receive points for correct answer" + assert @game_player.points.positive?, "Player should receive points for correct answer" end test "should create player answer with incorrect answer" do From c89cff5aa0175710ca85f1e55f01c17ee759a81a Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:11:25 +0100 Subject: [PATCH 11/22] chore: more rubocop --- test/controllers/game_player_answers_controller_test.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb index 286c14b..fc12f48 100644 --- a/test/controllers/game_player_answers_controller_test.rb +++ b/test/controllers/game_player_answers_controller_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "test_helper" class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest From c2bfe9873f46678c27774d067004e2f038d53472 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:17:51 +0100 Subject: [PATCH 12/22] chore: review --- app/controllers/game_player_answers_controller.rb | 2 +- app/models/game_player.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index 7089f35..7e739bd 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -17,7 +17,7 @@ def create ) time_at_answer = Time.current - time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : 0 + time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : nil if player_answer.save if @answer.correct diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 24ae92c..71e3d16 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -37,7 +37,7 @@ def find_answer_for(game_question) # the sum of the current question points with all the bonuses they can get # # @param [Integer] question_points - # @param [Integer] time_taken + # @param [Integer, nil] time_taken # @return [GamePlayer] def award_points!(question_points, time_taken) speed_bonus = calculate_speed_bonus(question_points, time_taken) @@ -62,13 +62,13 @@ def award_points!(question_points, time_taken) # # For low-value questions (<= 10 points), a simple tiered bonus is applied # - # time_taken is always expected to be >= 0 + # time_taken is expected to be >= 0, or nil # # @param [Integer] question_points - # @param [Integer] time_taken + # @param [Integer, nil] time_taken # @return [Integer] def calculate_speed_bonus(question_points, time_taken) - return 0 if time_taken > 8 + return 0 if time_taken.blank? || time_taken > 8 if question_points > 10 max_bonus = (question_points * 0.3).round From 9a02bbe70b9a302d51398a30bc375dbdcf63e4e7 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:33:37 +0100 Subject: [PATCH 13/22] chore: review --- app/models/game_player.rb | 2 +- db/migrate/20250403171237_add_current_streak_to_game_players.rb | 2 +- db/schema.rb | 2 +- test/fixtures/game_players.yml | 2 +- test/models/game_player_test.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 71e3d16..0c825de 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -5,7 +5,7 @@ # Table name: game_players # # id :integer not null, primary key -# current_streak :integer +# current_streak :integer default(0), not null # points :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/db/migrate/20250403171237_add_current_streak_to_game_players.rb b/db/migrate/20250403171237_add_current_streak_to_game_players.rb index c2ebed9..4ac6017 100644 --- a/db/migrate/20250403171237_add_current_streak_to_game_players.rb +++ b/db/migrate/20250403171237_add_current_streak_to_game_players.rb @@ -1,7 +1,7 @@ class AddCurrentStreakToGamePlayers < ActiveRecord::Migration[8.1] def change - add_column :game_players, :current_streak, :integer + add_column :game_players, :current_streak, :integer, default: 0, null: false end end diff --git a/db/schema.rb b/db/schema.rb index 46be422..ba9b070 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -56,7 +56,7 @@ t.integer "points", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "current_streak" + t.integer "current_streak", default: 0, null: false t.index ["game_id"], name: "index_game_players_on_game_id" t.index ["user_id"], name: "index_game_players_on_user_id" end diff --git a/test/fixtures/game_players.yml b/test/fixtures/game_players.yml index 4ad8724..1d4cfdb 100644 --- a/test/fixtures/game_players.yml +++ b/test/fixtures/game_players.yml @@ -5,7 +5,7 @@ # Table name: game_players # # id :integer not null, primary key -# current_streak :integer +# current_streak :integer default(0), not null # points :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index 7febe5f..0ad437a 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -5,7 +5,7 @@ # Table name: game_players # # id :integer not null, primary key -# current_streak :integer +# current_streak :integer default(0), not null # points :integer default(0), not null # created_at :datetime not null # updated_at :datetime not null From b4b7807b5d6211d8a2eed7c7d8101d865480d563 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:37:46 +0100 Subject: [PATCH 14/22] chore: review --- app/models/game_player.rb | 2 +- test/models/game_player_test.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 0c825de..5b3e188 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -68,7 +68,7 @@ def award_points!(question_points, time_taken) # @param [Integer, nil] time_taken # @return [Integer] def calculate_speed_bonus(question_points, time_taken) - return 0 if time_taken.blank? || time_taken > 8 + return 0 if time_taken.blank? || time_taken.negative? || time_taken > 8 if question_points > 10 max_bonus = (question_points * 0.3).round diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index 0ad437a..a3e704c 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -225,9 +225,9 @@ def setup assert_equal 300, bonus end - test "high-point negative time (should scale up)" do + test "negative time" do bonus = @game_player.send(:calculate_speed_bonus, 50, -2) - assert_equal 15, bonus + assert_equal 0, bonus end # Low-point questions (<= 10) – fixed tiers From 7b703a2721d2dd5a656d4a8d16278d1bc34c8bfd Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:43:09 +0100 Subject: [PATCH 15/22] chore: rubocop --- .rubocop.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 462884a..5765d73 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -147,3 +147,6 @@ Metrics/ClassLength: Metrics/AbcSize: Enabled: false + +Metrics/CyclomaticComplexity: + Max: 8 From 45573d60131a4d3321e0c317f9debce505c0900b Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:53:30 +0100 Subject: [PATCH 16/22] chore: rubocop --- app/models/game_player.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 5b3e188..b3a80d6 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -99,11 +99,9 @@ def calculate_streak_bonus(question_points) # @return [Integer] def streak_length - @streak_length ||= begin - recent_answers = player_answers.order(created_at: :desc).pluck(:correct) - streak = recent_answers.take_while { |correct| correct }.count - [streak, 1].max - end + recent_answers = player_answers.order(created_at: :desc).pluck(:correct) + streak = recent_answers.take_while { |correct| correct }.count + [streak, 1].max end end From 946e6c2307b60add148aae801b35385e3410cd67 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Tue, 8 Apr 2025 11:59:35 +0100 Subject: [PATCH 17/22] chore: more tests --- 1 | 11 +++++++ test/models/game_question_test.rb | 52 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 1 diff --git a/1 b/1 new file mode 100644 index 0000000..445249d --- /dev/null +++ b/1 @@ -0,0 +1,11 @@ +chore: review + +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# Date: Tue Apr 8 11:53:30 2025 +0100 +# +# On branch time-bonuses +# Changes to be committed: +# modified: app/models/game_player.rb +# diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index cd57e78..bcdd034 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -61,6 +61,58 @@ def setup assert_equal "idle", new_game_question.current_phase end + # Callbacks + test "does not change started_at when transitioning from answering to completed" do + original_time = 2.minutes.ago + @game_question.update!(current_phase: "answering", started_at: original_time) + + @game_question.current_phase = "completed" + @game_question.save! + + assert_equal original_time.to_i, @game_question.started_at.to_i + end + + test "does not set started_at when directly creating with answering phase" do + freeze_time do + now = Time.current + new_question = GameQuestion.create!( + game: games(:one), + question: questions(:one), + current_phase: "answering" + ) + + assert_equal now, new_question.started_at + end + end + + test "does not set started_at when updating other attributes while in answering phase" do + original_time = 2.minutes.ago + @game_question.update!(current_phase: "answering", started_at: original_time) + + @game_question.updated_at = Time.current + @game_question.save! + + assert_equal original_time.to_i, @game_question.started_at.to_i + end + + test "preserves nil started_at when changing between non-answering phases" do + @game_question.update!(current_phase: "idle", started_at: nil) + + @game_question.current_phase = "reading" + @game_question.save! + + assert_nil @game_question.started_at + end + + test "started_at is not set when changing to answering but validation fails" do + @game_question.game = nil + @game_question.current_phase = "answering" + + assert_not @game_question.save + + assert_nil @game_question.started_at + end + # Methods test "answers_count should return correct counts" do player_answer = player_answers(:one) From d0d17826b059c10cb147008d17fc5e5c86e494ed Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Sat, 26 Apr 2025 22:37:10 +0100 Subject: [PATCH 18/22] reward players and record streaks --- 1 | 11 ------ .../game_player_answers_controller.rb | 15 +++----- app/models/game_question.rb | 20 +++++++++- app/models/player_answer.rb | 1 + ...114324_add_time_taken_to_player_answers.rb | 7 ++++ db/schema.rb | 3 +- .../game_player_answers_controller_test.rb | 38 +------------------ test/fixtures/game_players.yml | 8 ++++ test/fixtures/player_answers.yml | 1 + test/fixtures/users.yml | 9 ++++- test/models/game_player_test.rb | 2 +- test/models/game_question_test.rb | 36 +++++++++++++++++- test/models/player_answer_test.rb | 1 + 13 files changed, 88 insertions(+), 64 deletions(-) delete mode 100644 1 create mode 100644 db/migrate/20250408114324_add_time_taken_to_player_answers.rb diff --git a/1 b/1 deleted file mode 100644 index 445249d..0000000 --- a/1 +++ /dev/null @@ -1,11 +0,0 @@ -chore: review - -# Please enter the commit message for your changes. Lines starting -# with '#' will be ignored, and an empty message aborts the commit. -# -# Date: Tue Apr 8 11:53:30 2025 +0100 -# -# On branch time-bonuses -# Changes to be committed: -# modified: app/models/game_player.rb -# diff --git a/app/controllers/game_player_answers_controller.rb b/app/controllers/game_player_answers_controller.rb index 7e739bd..2fe56f3 100644 --- a/app/controllers/game_player_answers_controller.rb +++ b/app/controllers/game_player_answers_controller.rb @@ -9,23 +9,18 @@ class GamePlayerAnswersController < ApplicationController # POST /games/:id/player_answer def create + time_at_answer = Time.current + time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : nil + player_answer = PlayerAnswer.build( game_player: @game_player, game_question: @game_question, answer: @answer, - correct: @answer.correct + correct: @answer.correct, + time_taken: time_taken ) - time_at_answer = Time.current - time_taken = @game_question.started_at.present? ? (time_at_answer - @game_question.started_at).round : nil - if player_answer.save - if @answer.correct - @game_player.award_points!(@game_question.question.points, time_taken) - else - @game_player.update(current_streak: 0) - end - render json: { message: "Answer submitted successfully" }, status: :ok else render json: { error: @player_answer.errors.full_messages }, status: :unprocessable_entity diff --git a/app/models/game_question.rb b/app/models/game_question.rb index c21b2a8..e8a0aa3 100644 --- a/app/models/game_question.rb +++ b/app/models/game_question.rb @@ -26,6 +26,7 @@ class GameQuestion < ApplicationRecord has_many :player_answers, dependent: :destroy before_save :set_started_at_if_answering + before_save :reward_players_if_completed def answers_count player_answers.group(:answer_id).count @@ -34,9 +35,26 @@ def answers_count private def set_started_at_if_answering - return unless current_phase_changed? && answering? && started_at.nil? + return unless current_phase_changed?(from: :reading, to: :answering) + return unless started_at.nil? self.started_at = Time.current end + def reward_players_if_completed + return unless current_phase_changed?(from: :answering, to: :completed) + + all_players = game.game_players.includes(:player_answers) + all_players.find_each do |player| + current_question_answer = player.player_answers.find_by(game_question_id: id) + if current_question_answer&.correct + player.award_points!(question.points, current_question_answer.time_taken) + else + player.update(current_streak: 0) + end + end + + true + end + end diff --git a/app/models/player_answer.rb b/app/models/player_answer.rb index 664900f..0e525ce 100644 --- a/app/models/player_answer.rb +++ b/app/models/player_answer.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null diff --git a/db/migrate/20250408114324_add_time_taken_to_player_answers.rb b/db/migrate/20250408114324_add_time_taken_to_player_answers.rb new file mode 100644 index 0000000..0a64571 --- /dev/null +++ b/db/migrate/20250408114324_add_time_taken_to_player_answers.rb @@ -0,0 +1,7 @@ +class AddTimeTakenToPlayerAnswers < ActiveRecord::Migration[8.1] + + def change + add_column :player_answers, :time_taken, :integer + end + +end diff --git a/db/schema.rb b/db/schema.rb index ba9b070..080d6cc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_04_03_171237) do +ActiveRecord::Schema[8.1].define(version: 2025_04_08_114324) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -93,6 +93,7 @@ t.boolean "correct", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "time_taken" t.index ["answer_id"], name: "index_player_answers_on_answer_id" t.index ["game_player_id"], name: "index_player_answers_on_game_player_id" t.index ["game_question_id"], name: "index_player_answers_on_game_question_id" diff --git a/test/controllers/game_player_answers_controller_test.rb b/test/controllers/game_player_answers_controller_test.rb index fc12f48..6d5d53d 100644 --- a/test/controllers/game_player_answers_controller_test.rb +++ b/test/controllers/game_player_answers_controller_test.rb @@ -15,7 +15,7 @@ class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest set_current_user(@user) end - test "should create player answer with correct answer" do + test "should create player answer" do @game_question.update(started_at: 10.seconds.ago) assert_difference("PlayerAnswer.count") do @@ -28,42 +28,6 @@ class GamePlayerAnswersControllerTest < ActionDispatch::IntegrationTest assert_response :success response_json = JSON.parse(response.body) assert_equal "Answer submitted successfully", response_json["message"] - - @game_player.reload - assert @game_player.points.positive?, "Player should receive points for correct answer" - end - - test "should create player answer with incorrect answer" do - @game_question.update(started_at: 10.seconds.ago) - - @game_player.update(current_streak: 5) - - assert_difference("PlayerAnswer.count") do - post player_answer_game_path(@game), params: { - game_question_id: @game_question.id, - selected_answer_id: @incorrect_answer.id - } - end - - assert_response :success - response_json = JSON.parse(response.body) - assert_equal "Answer submitted successfully", response_json["message"] - - @game_player.reload - assert_equal 0, @game_player.current_streak, "Player streak should reset to 0 after incorrect answer" - end - - test "should handle answer when game_question has no started_at time" do - @game_question.update(started_at: nil) - - assert_difference("PlayerAnswer.count") do - post player_answer_game_path(@game), params: { - game_question_id: @game_question.id, - selected_answer_id: @correct_answer.id - } - end - - assert_response :success end end diff --git a/test/fixtures/game_players.yml b/test/fixtures/game_players.yml index 1d4cfdb..68843b7 100644 --- a/test/fixtures/game_players.yml +++ b/test/fixtures/game_players.yml @@ -21,8 +21,16 @@ one: game: one user: one points: 0 + current_streak: 3 two: game: one user: two points: 0 + current_streak: 2 + +three: + game: one + user: three + points: 0 + current_streak: 1 diff --git a/test/fixtures/player_answers.yml b/test/fixtures/player_answers.yml index 31080a5..b7c2162 100644 --- a/test/fixtures/player_answers.yml +++ b/test/fixtures/player_answers.yml @@ -10,6 +10,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 474bf43..2f75745 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -20,7 +20,7 @@ one: name: John Doe - email: john.doe@email.dev + email: john.doe@email.dev first_name: John last_name: Doe image: https://example.com @@ -31,3 +31,10 @@ two: first_name: Doe last_name: John image: https://example.com + +three: + name: George Three + email: george.three@email.dev + first_name: George + last_name: Three + image: https://example.com diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index a3e704c..581102e 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -63,7 +63,7 @@ def setup player1.update!(points: 20) player2.update!(points: 50) - result = GamePlayer.by_points + result = GamePlayer.where(id: [player1, player2]).by_points assert_equal [player2, player1], result end diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index bcdd034..c1caa1a 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -74,14 +74,14 @@ def setup test "does not set started_at when directly creating with answering phase" do freeze_time do - now = Time.current + Time.current new_question = GameQuestion.create!( game: games(:one), question: questions(:one), current_phase: "answering" ) - assert_equal now, new_question.started_at + assert_equal nil, new_question.started_at end end @@ -123,6 +123,7 @@ def setup end test "sets started_at when changing to answering and it's nil" do + @game_question.reading! assert_nil @game_question.started_at @game_question.current_phase = "answering" @@ -151,4 +152,35 @@ def setup assert_nil @game_question.started_at end + test "reward_players_if_completed awards points to correct answers and resets streaks for incorrect or missing answers" do + @game_question.answering! + @game = @game_question.game + @question = @game_question.question + + # Create players + @player1 = game_players(:one) + @player2 = game_players(:two) + @player3 = game_players(:three) # Player 3 does not answer + + GamePlayer.any_instance.stubs(:streak_length).returns(4) # increase length by 1 + + # Transition the phase to completed + @game_question.current_phase = :completed + @game_question.save! + + # Reload players to check updates + @player1.reload + @player2.reload + @player3.reload + + # Assertions for player1 (correct answer) + assert_equal 4, @player1.current_streak # Streak remains unchanged + + # Assertions for player2 (incorrect answer) + assert_equal 0, @player2.current_streak # Streak reset to 0 + + # Assertions for player3 (no answer) + assert_equal 0, @player3.current_streak # Streak reset to 0 + end + end diff --git a/test/models/player_answer_test.rb b/test/models/player_answer_test.rb index 4b9ec2d..675023b 100644 --- a/test/models/player_answer_test.rb +++ b/test/models/player_answer_test.rb @@ -6,6 +6,7 @@ # # id :integer not null, primary key # correct :boolean not null +# time_taken :integer # created_at :datetime not null # updated_at :datetime not null # answer_id :integer not null From 59e6afc0835b8cebd43b09cdb471811452d24f11 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Sat, 26 Apr 2025 22:41:14 +0100 Subject: [PATCH 19/22] rubocop --- test/models/game_question_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index c1caa1a..037020f 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -152,7 +152,7 @@ def setup assert_nil @game_question.started_at end - test "reward_players_if_completed awards points to correct answers and resets streaks for incorrect or missing answers" do + test "reward_players_if_completed awards points to correct answers and resets streaks for incorrect or no answers" do @game_question.answering! @game = @game_question.game @question = @game_question.question From 53908f078efb5f272f714f3359e10dbc93ec50b0 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Sat, 26 Apr 2025 23:28:51 +0100 Subject: [PATCH 20/22] change question points and duration cap --- app/models/game_player.rb | 24 ++---- app/models/question.rb | 8 +- app/views/questions/_form.html.erb | 4 +- ...change_questions_duration_default_value.rb | 13 ++++ db/schema.rb | 4 +- test/fixtures/questions.yml | 8 +- test/models/game_player_test.rb | 77 +++---------------- test/models/game_question_test.rb | 2 +- test/models/question_test.rb | 20 ++--- 9 files changed, 54 insertions(+), 106 deletions(-) create mode 100644 db/migrate/20250426220951_change_questions_duration_default_value.rb diff --git a/app/models/game_player.rb b/app/models/game_player.rb index b3a80d6..8f2f029 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -54,13 +54,7 @@ def award_points!(question_points, time_taken) private # Calculates the bonus points a player earns for answering quickly. - # - # For high-value questions (> 10 points), the bonus is a percentage (30%) - # of the question's value, scaled linearly based on how fast the answer was submitted. - # Full bonus is awarded if answered in 2 seconds or less, and the bonus decreases - # to zero by 8 seconds. Bonus is rounded to the nearest integer. - # - # For low-value questions (<= 10 points), a simple tiered bonus is applied + # A simple tiered bonus is applied. # # time_taken is expected to be >= 0, or nil # @@ -70,17 +64,11 @@ def award_points!(question_points, time_taken) def calculate_speed_bonus(question_points, time_taken) return 0 if time_taken.blank? || time_taken.negative? || time_taken > 8 - if question_points > 10 - max_bonus = (question_points * 0.3).round - scale = [(8 - time_taken) / 6.0, 1.0].min - (scale * max_bonus).round - else - case time_taken - when 0..2 then 3 - when 2..4 then 2 - when 4..6 then 1 - else 0 - end + case time_taken + when 0..2 then 3 + when 2..4 then 2 + when 4..6 then 1 + else 0 end end diff --git a/app/models/question.rb b/app/models/question.rb index bc32021..2f619c7 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -5,7 +5,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -36,13 +36,13 @@ class Question < ApplicationRecord numericality: { only_integer: true, greater_than: 0, - less_than_or_equal_to: 100 + less_than_or_equal_to: 10 } validates :duration, numericality: { only_integer: true, - greater_than: 30, - less_than_or_equal_to: 240 + greater_than_or_equal_to: 10, + less_than_or_equal_to: 100 } validate :image_size diff --git a/app/views/questions/_form.html.erb b/app/views/questions/_form.html.erb index 6b1bb2b..2b28441 100644 --- a/app/views/questions/_form.html.erb +++ b/app/views/questions/_form.html.erb @@ -6,10 +6,10 @@ <%= form.textarea :text, required: true %> <%= form.label :points %> - <%= form.number_field :points, min: 0, max: 100 %> + <%= form.number_field :points, min: 0, max: 10 %> <%= form.label :duration %> - <%= form.number_field :duration, min: 30, max: 240 %> + <%= form.number_field :duration, min: 10, max: 100 %> <%= form.label :image %> <% if question.image.representable? %> diff --git a/db/migrate/20250426220951_change_questions_duration_default_value.rb b/db/migrate/20250426220951_change_questions_duration_default_value.rb new file mode 100644 index 0000000..e83e771 --- /dev/null +++ b/db/migrate/20250426220951_change_questions_duration_default_value.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ChangeQuestionsDurationDefaultValue < ActiveRecord::Migration[8.1] + + def up + change_column_default :questions, :duration, from: 120, to: 40 + end + + def down + change_column_default :questions, :duration, from: 40, to: 120 + end + +end diff --git a/db/schema.rb b/db/schema.rb index 080d6cc..639529c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_04_08_114324) do +ActiveRecord::Schema[8.1].define(version: 2025_04_26_220951) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -102,7 +102,7 @@ create_table "questions", force: :cascade do |t| t.integer "quiz_id", null: false t.text "text" - t.integer "duration", default: 120, null: false + t.integer "duration", default: 40, null: false t.integer "points", default: 1 t.integer "position" t.datetime "created_at", null: false diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml index 87d5b75..bf57c92 100644 --- a/test/fixtures/questions.yml +++ b/test/fixtures/questions.yml @@ -3,7 +3,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -21,14 +21,14 @@ one: quiz: one text: Question One - points: 10 + points: 3 two: quiz: two text: Question Two - points: 20 + points: 6 three: quiz: one text: Question Three - points: 30 + points: 9 diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index 581102e..ac8ce5d 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -88,28 +88,26 @@ def setup test "awards only question points" do @game_player.update!(points: 10) - @game_player.award_points!(20, 100) # too late = 0 bonus - assert_equal 30, @game_player.reload.points + @game_player.award_points!(10, 100) # too late = 0 bonus + assert_equal 20, @game_player.reload.points end test "awards question and bonus points" do @game_player.update!(points: 0) - @game_player.award_points!(20, 0) # max bonus - expected = 20 + 6 # 20 * 0.3 = 6 → full scale = 6 - assert_equal expected, @game_player.reload.points + @game_player.award_points!(10, 0) # max bonus + assert_equal 13, @game_player.reload.points end test "awards max bonus for high-value question answered fast" do @game_player.update!(points: 0) - @game_player.award_points!(100, 2) - expected = 100 + 30 # 100 * 0.3 = 30, full bonus at 2s - assert_equal expected, @game_player.reload.points + @game_player.award_points!(10, 2) # full bonus 2s + assert_equal 13, @game_player.reload.points end test "handles bonus cutoff at 8 seconds" do @game_player.update!(points: 0) - @game_player.award_points!(100, 8) # bonus cutoff - assert_equal 100, @game_player.reload.points + @game_player.award_points!(9, 8) # bonus cutoff + assert_equal 9, @game_player.reload.points end test "handles multiple awards correctly" do @@ -134,16 +132,15 @@ def setup test "awards max bonus at edge boundary time 2s" do @game_player.update!(points: 0) - @game_player.award_points!(100, 2) + @game_player.award_points!(10, 2) # still considered full scale bonus - assert_equal 130, @game_player.reload.points + assert_equal 13, @game_player.reload.points end test "awards partial bonus at midpoint time 5s" do @game_player.update!(points: 0) - @game_player.award_points!(100, 5) - # scale = (8 - 5) / 6 = 0.5 → bonus = 15 - assert_equal 115, @game_player.reload.points + @game_player.award_points!(10, 5) + assert_equal 11, @game_player.reload.points end test "awards 1 bonus point for low-value question at 5s" do @@ -182,56 +179,6 @@ def setup # calculate_speed_bonus - # High-point questions (points > 10) – exponential-like scaling - test "high-point fast answer (0s)" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 0) - assert_equal 12, bonus # 40 * 0.3 = 12, scale = 1 (max possible cuz quick) -> bonus = 12 - end - - test "high-point exact 2s" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 2) - # scale = (8 - 2) / 6 = 1.0 → 12 bonus - assert_equal 12, bonus - end - - test "high-point exact 4s" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 4) - assert_equal 8, bonus - end - - test "high-point exact 6s" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 6) - assert_equal 4, bonus - end - - test "high-point exact 8s" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 8) - assert_equal 0, bonus - end - - test "high-point just under 8s" do - bonus = @game_player.send(:calculate_speed_bonus, 40, 7.9) - assert_equal 0, bonus - end - - test "high-point borderline (10 points) should behave like low-point" do - bonus = @game_player.send(:calculate_speed_bonus, 10, 1) - assert_equal 3, bonus - end - - test "very high-point question fast answer" do - bonus = @game_player.send(:calculate_speed_bonus, 1000, 0) - # max_bonus = 300, scale = 8 / 6 = 1..., bonus = 300 - assert_equal 300, bonus - end - - test "negative time" do - bonus = @game_player.send(:calculate_speed_bonus, 50, -2) - assert_equal 0, bonus - end - - # Low-point questions (<= 10) – fixed tiers - test "low-point exact 2s" do bonus = @game_player.send(:calculate_speed_bonus, 6, 2) assert_equal 3, bonus diff --git a/test/models/game_question_test.rb b/test/models/game_question_test.rb index 037020f..b341652 100644 --- a/test/models/game_question_test.rb +++ b/test/models/game_question_test.rb @@ -81,7 +81,7 @@ def setup current_phase: "answering" ) - assert_equal nil, new_question.started_at + assert_nil new_question.started_at end end diff --git a/test/models/question_test.rb b/test/models/question_test.rb index ecdc9bb..4a0b7b0 100644 --- a/test/models/question_test.rb +++ b/test/models/question_test.rb @@ -5,7 +5,7 @@ # Table name: questions # # id :integer not null, primary key -# duration :integer default(120), not null +# duration :integer default(40), not null # points :integer default(1) # position :integer # text :text @@ -50,32 +50,32 @@ def setup end test "validates points within range" do - question = Question.new(text: "Sample Question", quiz: quizzes(:one), duration: 120) + question = Question.new(text: "Sample Question", quiz: quizzes(:one)) question.points = 0 assert_not question.valid? assert_includes question.errors[:points], "must be greater than 0" - question.points = 101 + question.points = 11 assert_not question.valid? - assert_includes question.errors[:points], "must be less than or equal to 100" + assert_includes question.errors[:points], "must be less than or equal to 10" - question.points = 50 + question.points = 5 assert question.valid? end test "validates duration within range" do question = Question.new(text: "Sample Question", quiz: quizzes(:one), points: 10) - question.duration = 29 + question.duration = 9 assert_not question.valid? - assert_includes question.errors[:duration], "must be greater than 30" + assert_includes question.errors[:duration], "must be greater than or equal to 10" - question.duration = 241 + question.duration = 101 assert_not question.valid? - assert_includes question.errors[:duration], "must be less than or equal to 240" + assert_includes question.errors[:duration], "must be less than or equal to 100" - question.duration = 120 + question.duration = 90 assert question.valid? end From f407c413a713db4c4c661988a3991fb78606d1d4 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Sat, 26 Apr 2025 23:34:53 +0100 Subject: [PATCH 21/22] rubocop --- app/models/game_player.rb | 7 +++---- test/models/game_player_test.rb | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/models/game_player.rb b/app/models/game_player.rb index 8f2f029..d3fb52d 100644 --- a/app/models/game_player.rb +++ b/app/models/game_player.rb @@ -40,12 +40,12 @@ def find_answer_for(game_question) # @param [Integer, nil] time_taken # @return [GamePlayer] def award_points!(question_points, time_taken) - speed_bonus = calculate_speed_bonus(question_points, time_taken) streak_bonus = calculate_streak_bonus(question_points) + speed_bonus = calculate_speed_bonus(time_taken) with_lock do update_columns( - points: points + question_points + speed_bonus + streak_bonus, + points: points + question_points + streak_bonus + speed_bonus, current_streak: streak_length ) end @@ -58,10 +58,9 @@ def award_points!(question_points, time_taken) # # time_taken is expected to be >= 0, or nil # - # @param [Integer] question_points # @param [Integer, nil] time_taken # @return [Integer] - def calculate_speed_bonus(question_points, time_taken) + def calculate_speed_bonus(time_taken) return 0 if time_taken.blank? || time_taken.negative? || time_taken > 8 case time_taken diff --git a/test/models/game_player_test.rb b/test/models/game_player_test.rb index ac8ce5d..095ad30 100644 --- a/test/models/game_player_test.rb +++ b/test/models/game_player_test.rb @@ -177,50 +177,50 @@ def setup # Private methods - # calculate_speed_bonus + # calculate_speed_bonus, test "low-point exact 2s" do - bonus = @game_player.send(:calculate_speed_bonus, 6, 2) + bonus = @game_player.send(:calculate_speed_bonus, 2) assert_equal 3, bonus end test "low-point exact 4s" do - bonus = @game_player.send(:calculate_speed_bonus, 6, 4) + bonus = @game_player.send(:calculate_speed_bonus, 4) assert_equal 2, bonus # falls into 2..4 range (.. is inclusive) end test "low-point edge 6s" do - bonus = @game_player.send(:calculate_speed_bonus, 6, 6) + bonus = @game_player.send(:calculate_speed_bonus, 6) assert_equal 1, bonus end test "low-point above 6s" do - bonus = @game_player.send(:calculate_speed_bonus, 6, 6.1) + bonus = @game_player.send(:calculate_speed_bonus, 6.1) assert_equal 0, bonus end test "low-point just under 2s" do - bonus = @game_player.send(:calculate_speed_bonus, 10, 1.9) + bonus = @game_player.send(:calculate_speed_bonus, 1.9) assert_equal 3, bonus end test "low-point negative time (should still max tier)" do - bonus = @game_player.send(:calculate_speed_bonus, 8, -1) + bonus = @game_player.send(:calculate_speed_bonus, -1) assert_equal 0, bonus end test "zero-point question" do - bonus = @game_player.send(:calculate_speed_bonus, 0, 1) + bonus = @game_player.send(:calculate_speed_bonus, 1) assert_equal 3, bonus # falls under low-point logic end test "negative-point question" do - bonus = @game_player.send(:calculate_speed_bonus, -5, 1) + bonus = @game_player.send(:calculate_speed_bonus, 1) assert_equal 3, bonus # still low-point logic end test "bonus never negative even with high time" do - bonus = @game_player.send(:calculate_speed_bonus, 100, 100) + bonus = @game_player.send(:calculate_speed_bonus, 100) assert_equal 0, bonus end From 102d53b0d802be901b173fa5c29ced33428bdad9 Mon Sep 17 00:00:00 2001 From: Georgi Ganchev Date: Wed, 30 Apr 2025 22:12:30 +0100 Subject: [PATCH 22/22] optional answers --- app/models/game_question.rb | 4 ++++ app/models/player_answer.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/game_question.rb b/app/models/game_question.rb index e8a0aa3..d8a2815 100644 --- a/app/models/game_question.rb +++ b/app/models/game_question.rb @@ -47,6 +47,10 @@ def reward_players_if_completed all_players = game.game_players.includes(:player_answers) all_players.find_each do |player| current_question_answer = player.player_answers.find_by(game_question_id: id) + unless current_question_answer + player.player_answers.create(game_question: self, answer_id: -1, correct: false) + end + if current_question_answer&.correct player.award_points!(question.points, current_question_answer.time_taken) else diff --git a/app/models/player_answer.rb b/app/models/player_answer.rb index 0e525ce..e2dbc9a 100644 --- a/app/models/player_answer.rb +++ b/app/models/player_answer.rb @@ -21,7 +21,7 @@ # class PlayerAnswer < ApplicationRecord - belongs_to :answer + belongs_to :answer, optional: true belongs_to :game_player belongs_to :game_question