diff --git a/app/controllers/game_landing_controller.rb b/app/controllers/game_landing_controller.rb new file mode 100644 index 0000000..95795b4 --- /dev/null +++ b/app/controllers/game_landing_controller.rb @@ -0,0 +1,21 @@ +class GameLandingController < ApplicationController + include StudentAuthentication + + layout "embed", only: :embed + + def show + @game = Game.find(params[:id]) + @game_attempt_token = GameAttempt.generate_token(student_id: current_student.id, game_id: @game.id) + + logger.info("game attempt token: #{{ game_attempt_token: @game_attempt_token, student_id: current_student.id, game_id: @game.id }}") + end + + def embed + @game = Game.find(params[:id]) + end + + private + + def current_student = Current.student + helper_method :current_student +end diff --git a/app/javascript/controllers/quiz_controller.js b/app/javascript/controllers/quiz_controller.js new file mode 100644 index 0000000..5acdf8c --- /dev/null +++ b/app/javascript/controllers/quiz_controller.js @@ -0,0 +1,73 @@ +import { Controller } from "@hotwired/stimulus" + +const QUESTIONS = [ + { text: "The sky is blue.", answer: true }, + { text: "Fish can breathe air out of water.", answer: false }, + { text: "The earth orbits the sun.", answer: true } +] + +export default class extends Controller { + static targets = ["root"] + static values = { startUrl: String, finishUrl: String } + + connect() { + this.token = new URLSearchParams(window.location.search).get("token") + this.questionIndex = 0 + this.correctCount = 0 + this.started = false + this.renderQuestion() + } + + renderQuestion() { + const question = QUESTIONS[this.questionIndex] + this.rootTarget.innerHTML = ` +

${question.text}

+
+ + +
+ ` + this.rootTarget.querySelectorAll("button").forEach((button) => { + button.addEventListener("click", () => this.answer(button.dataset.answer === "true")) + }) + } + + async answer(value) { + if (!this.started) { + this.started = true + await this.post(this.startUrlValue) + } + + if (value === QUESTIONS[this.questionIndex].answer) this.correctCount += 1 + + this.questionIndex += 1 + if (this.questionIndex < QUESTIONS.length) { + this.renderQuestion() + } else { + await this.finish() + } + } + + async finish() { + const outcome = this.correctCount === QUESTIONS.length ? "pass" : "fail" + + await this.post(this.finishUrlValue, { + game_attempt: { outcome, score: this.correctCount } + }) + + this.rootTarget.innerHTML = `

You scored ${this.correctCount} out of ${QUESTIONS.length}.

` + } + + async post(url, extraParams = {}) { + const csrfToken = document.querySelector("meta[name='csrf-token']")?.content + + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken + }, + body: JSON.stringify({ token: this.token, ...extraParams }) + }) + } +} diff --git a/app/views/game_landing/embed.html.erb b/app/views/game_landing/embed.html.erb new file mode 100644 index 0000000..bbc39d5 --- /dev/null +++ b/app/views/game_landing/embed.html.erb @@ -0,0 +1,7 @@ +<%= tag.div data: { + controller: "quiz", + quiz_start_url_value: start_game_attempts_path, + quiz_finish_url_value: finish_game_attempts_path + } do %> +
+<% end %> diff --git a/app/views/game_landing/show.html.erb b/app/views/game_landing/show.html.erb new file mode 100644 index 0000000..78de03c --- /dev/null +++ b/app/views/game_landing/show.html.erb @@ -0,0 +1,7 @@ +<% content_for(:title, @game.title) %> + +

<%= @game.title %>

+ +<%= tag.iframe src: embed_game_path(@game, token: @game_attempt_token), + class: "w-full h-[600px] border-0", + title: @game.title %> diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb new file mode 100644 index 0000000..a1a5e11 --- /dev/null +++ b/app/views/layouts/embed.html.erb @@ -0,0 +1,16 @@ + + + + <%= content_for(:title) || "Endsideout" %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + diff --git a/config/routes.rb b/config/routes.rb index 48de98d..ba10797 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,12 @@ resource :student_session, only: %i[new create destroy] resources :student_homes, only: %i[index] + resources :games, only: [] do + member do + get :play, to: "game_landing#show" + get :embed, to: "game_landing#embed" + end + end resources :classroom_rosters, only: %i[show], param: :uuid resources :game_attempts, only: [] do collection do diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb new file mode 100644 index 0000000..1d00cd5 --- /dev/null +++ b/test/controllers/game_landing_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class GameLandingControllerTest < ActionDispatch::IntegrationTest + setup do + @student = students(:one) + @game = games(:one) + student_sign_in_as @student + end + + test "unauthenticated request redirects to student login" do + student_sign_out + get play_game_url(@game) + assert_redirected_to new_student_session_url + end + + test "show generates a game attempt token and embeds the game" do + get play_game_url(@game) + assert_response :success + + iframe_src = assert_select("iframe").first["src"] + token = Rack::Utils.parse_nested_query(URI(iframe_src).query)["token"] + assert_equal( + { "student_id" => @student.id, "game_id" => @game.id }, + GameAttempt.verify_token(token) + ) + end + + test "embed renders the quiz mount point" do + get embed_game_url(@game, token: "some-token") + assert_response :success + assert_select "[data-controller='quiz']" + end +end diff --git a/test/fixtures/game_attempts.yml b/test/fixtures/game_attempts.yml index 8db4e1f..8e0dd1f 100644 --- a/test/fixtures/game_attempts.yml +++ b/test/fixtures/game_attempts.yml @@ -1,7 +1,6 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - score: 1 outcome: completed started_at: 2026-08-29 11:53:38 finished_at: 2026-08-29 11:53:38 @@ -10,7 +9,6 @@ one: token: onetoken two: - score: 1 outcome: expired started_at: 2026-08-29 11:53:38 finished_at: 2026-08-29 11:53:38