Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/controllers/game_landing_controller.rb
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions app/javascript/controllers/quiz_controller.js
Original file line number Diff line number Diff line change
@@ -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 = `
<p class="text-2xl p-8 border-2">${question.text}</p>
<div class="mt-4 space-x-4">
<button type="button" data-answer="true" class="bg-green-800 text-white px-4 py-2 rounded">True</button>
<button type="button" data-answer="false" class="bg-red-800 text-white px-4 py-2 rounded">False</button>
</div>
`
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 = `<p>You scored ${this.correctCount} out of ${QUESTIONS.length}.</p>`
}

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 })
})
}
}
7 changes: 7 additions & 0 deletions app/views/game_landing/embed.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
<div data-quiz-target="root"></div>
<% end %>
7 changes: 7 additions & 0 deletions app/views/game_landing/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<% content_for(:title, @game.title) %>

<h1 class="text-xl font-bold mb-4"><%= @game.title %></h1>

<%= tag.iframe src: embed_game_path(@game, token: @game_attempt_token),
class: "w-full h-[600px] border-0",
title: @game.title %>
16 changes: 16 additions & 0 deletions app/views/layouts/embed.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title><%= content_for(:title) || "Endsideout" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>

<body>
<%= yield %>
</body>
</html>
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions test/controllers/game_landing_controller_test.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions test/fixtures/game_attempts.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading