Mattias Bodlund 10 hours ago
parent
commit
d6735cb4e1
15 changed files with 240 additions and 32 deletions
  1. +38
    -0
      AGENTS.md
  2. +9
    -0
      GEMINI.md
  3. +13
    -0
      app/assets/stylesheets/application.css
  4. +1
    -1
      app/controllers/players_controller.rb
  5. +59
    -12
      app/controllers/stages_controller.rb
  6. +30
    -4
      app/models/node.rb
  7. +8
    -0
      app/models/player.rb
  8. +2
    -2
      app/views/admin/nodes/_form.html.erb
  9. +2
    -0
      app/views/layouts/application.html.erb
  10. +19
    -0
      app/views/stages/chance.html.erb
  11. +20
    -0
      app/views/stages/choice.html.erb
  12. +15
    -0
      app/views/stages/result.html.erb
  13. +5
    -5
      app/views/stages/show.html.erb
  14. +10
    -5
      config/locales/en.yml
  15. +9
    -3
      config/routes.rb

+ 38
- 0
AGENTS.md View File

@ -0,0 +1,38 @@
# IKEA Foundation - Week 2026 Project
This project is a Ruby on Rails application developed for the IKEA Foundation. It appears to be a multi-lingual, interactive game or educational tool ("Week 2026") where players progress through various stages, making choices or facing "chance" events.
## Key Features
- **Multi-lingual Support:** Uses the `mobility` gem for translating content into numerous languages (en, zh, hr, cs, da, nl, fi, fr, fr-CA, de, hu, it, ja, ko, nb, pl, pt, ro, sr, sk, sl, es, sv, uk).
- **Content Management:** A hierarchical "Node" system (using `ancestry`) for managing pages, stages, and interactive elements.
- **Player Progression:** Tracks player progress, scores, and decisions across different stages.
- **Interactive Stages:** Includes mechanics for "flipping" between "chance" and "choice" outcomes, sampling random results, and advancing through a sequence of stages.
- **Admin Interface:** A backend for managing nodes, assets (using Active Storage), users, and translations.
- **Background Jobs:** Uses `sidekiq` and `redis` for asynchronous processing.
- **Search:** Integrates `pg_search` for full-text search capabilities within the node/content system.
## Technical Stack
- **Framework:** Ruby on Rails 8.1.2
- **Language:** Ruby 3.4.9
- **Database:** PostgreSQL
- **Asset Pipeline:** Propshaft with Importmap-rails and Stimulus/Turbo.
- **Task Management:** Sidekiq with Redis.
- **Styling/UI:** Custom CSS/JS (Stimulus controllers for interactivity).
## Core Models
- `Node`: The central model for content, representing pages, stages, and interactive components in a tree structure.
- `Player`: Represents a session/user playing the game, tracking their `progress`, `current_stage`, and `score`.
- `Asset` & `Attachment`: Manage files and their association with nodes.
- `User`: Handles admin authentication and roles.
- `VerificationCode`: Likely used for secure admin login or two-step authentication.
## Project Structure
- `app/controllers/admin/`: Admin backend logic.
- `app/controllers/stages_controller.rb`: Main logic for the interactive game stages.
- `app/controllers/site_controller.rb`: Logic for rendering content nodes as pages.
- `config/locales/`: Translation files for all supported languages.
- `db/migrate/`: Database schema evolutions.

+ 9
- 0
GEMINI.md View File

@ -0,0 +1,9 @@
# Gemini CLI Project Configuration
This project contains documentation for AI agents in the [AGENTS.md](./AGENTS.md) file.
## Core Mandates
- Adhere to the project structure and architectural patterns described in [AGENTS.md](./AGENTS.md).
- Ensure all new features or bug fixes are verified through automated tests.
- Maintain multi-lingual support by updating the necessary translation files in `config/locales/`.

+ 13
- 0
app/assets/stylesheets/application.css View File

@ -95,6 +95,15 @@
font: 16px/1.3 var(--ff-ikea);
}
#debug {
position: fixed;
bottom: 0;
left: 0;
font: 0.75rem ui-monospace;
padding: 1em;
max-width: 40ch;
}
.turbo-progress-bar {
height: 3px;
background-color: #0058a3;
@ -231,6 +240,10 @@ p {
.action-container {
margin-block-start: 2em;
display: flex;
flex-direction: column;
gap: 0.4em;
}
button {


+ 1
- 1
app/controllers/players_controller.rb View File

@ -4,7 +4,7 @@ class PlayersController < ApplicationController
skip_before_action :require_player!
def create
reset_session
# reset_session
@player = Player.create(locale: I18n.locale)
session[:player_id] = @player.id


+ 59
- 12
app/controllers/stages_controller.rb View File

@ -3,34 +3,81 @@ class StagesController < ApplicationController
before_action :set_locale
before_action :require_player!
before_action :set_node, only: [ :show, :flip, :choose ]
before_action :set_stage
before_action :set_outcome, only: [ :reveal, :pick, :result ]
# GET
def show
@progress = current_player.progress[params[:id].to_s] || {}
end
# POST
def flip
outcome = rand < 0.5 ? "chance" : "choice"
current_player.record_flip(params[:id], outcome)
redirect_to stage_path(id: params[:id])
redirect_to url_for(action: "reveal")
end
# GET Chance or Choice
def reveal
@node = @stage.children.viewable.find_by(template: @outcome)
redirect_to root_path and return if @outcome.blank? or @node.blank?
render action: @outcome
end
# POST
def pick
redirect_to root_path and return if @outcome.blank?
@node = @stage.children.viewable.find_by(template: @outcome)
possible_answers = @node.children.viewable
if @outcome == "chance"
result = possible_answers.pluck(:template).sample
elsif @outcome == "choice"
result = possible_answers.find_by(id: params[:option])&.template
end
current_player.record_pick_result(params[:id], result) unless result.blank?
redirect_to action: "result"
end
def choose
option_index = params[:option].to_i
current_player.record_option(params[:id], option_index)
# Advance to next stage
current_player.advance_to_next_stage!
redirect_to stage_path(id: current_player.current_stage)
# GET
def result
redirect_to root_path and return if @outcome.blank?
@result = current_player.progress[params[:id].to_s]["result"]
question_node = @stage.children.viewable.find_by(template: @outcome)
@node = question_node.children.viewable.find_by(template: @result)
logger.info(current_player.inspect)
end
private
def set_node
@node = Node.at_depth(1).viewable.ordered[params[:id].to_i - 1]
if @node.nil?
def set_stage
@stage = Node.at_depth(1).viewable.ordered[params[:id].to_i - 1]
if @stage.nil?
redirect_to root_path
end
end
def set_outcome
@outcome ||= current_player.progress[params[:id].to_s]["flip"]
end
end

+ 30
- 4
app/models/node.rb View File

@ -26,12 +26,38 @@ class Node < ApplicationRecord
enum :status, { status_published: 0, status_draft: 1, status_archived: 2 }
enum :template, {
tmpl_stage: 0,
tmpl_chance: 1,
tmpl_choice: 2,
tmpl_bonus: 3
start: 0,
stage: 1,
choice: 2,
chance: 3,
bonus: 4,
best: 5,
bad: 6,
good: 7,
game_over: 8
}
def available_templates
return [ :start ] if root?
case depth
when 1
[ :stage ]
when 2
[ :choice, :chance, :bonus ]
when 3
if parent&.chance?
[ :good, :bad, :game_over ]
elsif parent&.choice?
[ :best, :good, :bad ]
else
[]
end
else
[]
end
end
include PgSearch::Model
pg_search_scope :pg_search,
against: { title: "A", url: "B", page_title: "A", page_description: "B", href: "B", slug: "B" },


+ 8
- 0
app/models/player.rb View File

@ -19,6 +19,14 @@ class Player < ApplicationRecord
save
end
# stage_index: 1, 2, 3...
# result: 'good', 'bad', 'game_over'
def record_pick_result(stage_index, result)
self.progress[stage_index.to_s] ||= {}
self.progress[stage_index.to_s]["result"] = result
save
end
def advance_to_next_stage!
self.current_stage += 1
save


+ 2
- 2
app/views/admin/nodes/_form.html.erb View File

@ -69,8 +69,8 @@
f: form,
attr: :template,
include_blank: false,
choices: Node.templates.map { |k,v| [t(k, scope: :'nodes.templates'), k] }.sort
} %>
choices: @node.available_templates.map { |k| [t(k, scope: :'nodes.templates'), k] }.sort
} %>
<%= render partial: 'material/tom_select_field',
locals: {


+ 2
- 0
app/views/layouts/application.html.erb View File

@ -32,5 +32,7 @@
</main>
<footer><span>© Inter IKEA Systems B.V. 2026</span></footer>
<%= tag.div content_for(:debug), id: "debug" if Rails.env.development? %>
</body>
</html>

+ 19
- 0
app/views/stages/chance.html.erb View File

@ -0,0 +1,19 @@
<%-
content_for :title, @node.page_title.blank? ? @node.title : @node.page_title
content_for :meta_description, @node.page_description
content_for :debug, current_player.inspect
%>
<article>
<% @node.attachments.each do |attachment| %>
<%= attachment.body.html_safe %>
<% end %>
<div class="action-container">
<%= button_to t("flip_the_card"), url_for(action: 'pick'), method: :post %>
</div>
</article>

+ 20
- 0
app/views/stages/choice.html.erb View File

@ -0,0 +1,20 @@
<%-
content_for :title, @node.page_title.blank? ? @node.title : @node.page_title
content_for :meta_description, @node.page_description
content_for :debug, current_player.inspect
%>
<article>
<% @node.attachments.each do |attachment| %>
<%= attachment.body.html_safe %>
<% end %>
<div class="action-container">
<% @node.children.viewable.ordered.each do |answer_option| %>
<%= button_to answer_option.title, url_for(action: 'pick', option: answer_option.id), method: :post %>
<% end %>
</div>
</article>

+ 15
- 0
app/views/stages/result.html.erb View File

@ -0,0 +1,15 @@
<%-
content_for :title, @node.page_title.blank? ? @node.title : @node.page_title
content_for :meta_description, @node.page_description
content_for :debug, current_player.inspect
%>
<article>
<% @node.attachments.each do |attachment| %>
<%= attachment.body.html_safe %>
<% end %>
</article>

+ 5
- 5
app/views/stages/show.html.erb View File

@ -1,12 +1,14 @@
<%-
content_for :title, @node.page_title.blank? ? @node.title : @node.page_title
content_for :meta_description, @node.page_description
content_for :title, @stage.page_title.blank? ? @stage.title : @stage.page_title
content_for :meta_description, @stage.page_description
content_for :debug, current_player.inspect
%>
<article>
<% @node.attachments.each do |attachment| %>
<% @stage.attachments.each do |attachment| %>
<%= attachment.body.html_safe %>
<% end %>
@ -14,6 +16,4 @@
<%= button_to t("flip"), url_for(action: 'flip'), method: :post %>
</div>
<%= current_player.inspect %>
</article>

+ 10
- 5
config/locales/en.yml View File

@ -196,11 +196,16 @@ en:
da: Danish
en: English
de: German
templates:
tmpl_stage: Stage
tmpl_chance: Chance
tmpl_choice: Choice
tmpl_bonus: Bonus chance
templates:
start: Start
stage: Stage
choice: Choice
chance: Chance
best: Best
bad: Bad
game_over: Game Over
good: Good
categories:
box: Box
folder: Folder


+ 9
- 3
config/routes.rb View File

@ -52,9 +52,15 @@ Rails.application.routes.draw do
scope ":locale", constraints: { locale: /en|zh|hr|cs|da|nl|fi|fr|fr-CA|de|hu|it|ja|ko|nb|pl|pt|ro|sr|sk|sl|es|sv|uk/ } do
post "start", to: "players#create", as: "start"
get "stages/:id", to: "stages#show", constraints: { id: /\d+/ }, as: :stage
post "stages/:id/flip", to: "stages#flip", constraints: { id: /\d+/ }, as: :flip_stage
post "stages/:id/choose", to: "stages#choose", constraints: { id: /\d+/ }, as: :choose_stage
scope "stages/:id", constraints: { id: /\d+/ } do
get "", to: "stages#show", as: "stage"
post "flip", to: "stages#flip"
get "reveal", to: "stages#reveal"
post "pick(/:option)", to: "stages#pick"
get "result", to: "stages#result"
end
get "", to: "languages#show"
# get "*url", to: "site#page", constraints: lambda { |req| req.path.exclude?("storage") }


Loading…
Cancel
Save