# IKEA Foundation - Week 2026 Project This project is a Ruby on Rails application developed for the IKEA Foundation. It is a multi-lingual, interactive game/educational tool 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. Supported locales include: 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. - **Hierarchical Content Management:** A "Node" system (using `ancestry`) for managing pages, stages, and interactive elements. - **Player Progression:** Tracks player progress, scores, and decisions across different stages in the `Player` model's `progress` JSONB field. - **Interactive Game Flow:** Managed by `GameController`, featuring: - **Stage:** Presents a stage's answer nodes (`good_answer`, `bad_answer`, or `chance`). - **Answer:** Records the player's selection; for `chance`, an outcome child is sampled. - **Stage result:** Shows the outcome node and applies its score. - **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. - **Scoring:** Answer scores live in `config/question_scores.json`, not in the database. See below. - **Analytics:** An admin dashboard at `/admin/:locale/analytics` reporting funnel drop-off, answer distribution and the results-screen thumbs vote. See below. - **Admin Interface:** A backend for managing nodes, assets (Active Storage), users, and translations. - **Search:** `pg_search` integration for content discovery. ## 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. - **Background Jobs:** Sidekiq with Redis. ## Core Models - `Node`: The central content model. Templates are hierarchy-dependent: - **Root (Depth 0):** `start` - **Level 1:** `facts`, `intro`, `stage`, `last_save`, `results` - **Level 2 (answers under a stage):** `good_answer`, `bad_answer`, `chance` - **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer` - `Player`: Tracks session state, `progress` (per-stage `answer_id` / `result_id`), the cumulative `score`, the `scores` hash keyed by `food_waste`, `emissions`, `income`, the furthest screen reached (`furthest_step`) and the results-screen thumbs vote (`rating` / `rated_at`). - `Asset` & `Attachment`: Handles media and its contextual content (body text, styling) associated with nodes. - `User`: Admin authentication and roles. ## Scoring Scores are **not** stored on nodes. `config/question_scores.json` holds them, and `GameController#score_entry_for` maps a node to its entry **by `position`**: - `stages[stage_index - 1].answers[answer.position - 1]` for a normal answer. - For a `chance` outcome, the parent chance node's entry is looked up the same way and the outcome is read from its `outcomes[child.position - 1]`. Each entry has an `overall` value (feeds `player.score` and the result band) and an `impact` hash of `food_waste` / `emissions` / `income` deltas (feed the per-category tones in `GameHelper`). `type` and `early_exit` are documentation only — nothing reads them. **Gotcha:** because the mapping is positional, the JSON must mirror the node tree exactly. A `chance` answer needs a `{"type": "chance", "outcomes": [...]}` entry at its position; if it is flattened into sibling entries instead, the outcome lookup silently returns `nil` and that branch scores nothing. Verify with a `bin/rails runner` walk of the tree after editing. ### Result band consistency guard The two axes can diverge: a good `chance` outcome gives only `overall: 1` where a safe good answer gives `2`, so a player can accumulate green impact but a low `overall` — and land a "close call" headline above three positive impact texts. `GameHelper#result_state` therefore raises the band from `overall` to at least `GameHelper#tone_floor`, computed from the same `impact_tone` values the impact texts use: all three positive floors at `:best`, none negative floors at `:balanced`. **The guard only lifts, never lowers** — a high `overall` still reaches `:best`, and any negative metric still allows `:worst`. The early-exit / last-save branch returns before the guard and is unaffected. **Consequence:** the headline and the three impact texts are no longer independent. Retuning `impact` values in `question_scores.json` can move the headline even when `overall` is untouched, and changing `IMPACT_TONE_BANDS` changes both the texts and the band floor. ### Results copy Each impact category needs three distinct tone strings — `positive`, `neutral`, `negative` under `game.results.` — in **all 24 locale files**. A missing tone is easy to miss because nothing raises: `neutral` was a verbatim copy of `positive` in every locale, so an `income: 0` player was told they "earned some money". When touching these, sweep all locales and check the three strings actually differ, not just that the key exists. ## Analytics There is no event log. `GameAnalytics` derives everything from two columns on `players`: - `progress` — what people *did* (the answer node they landed on for each stage). - `furthest_step` — where people *stopped*. Written by `GameController#track_step`, an `after_action` on the screen actions. `Player#record_step` only ever moves a player forward, so the browser back button can't rewind the funnel. Step names are `facts`, `intro`, `stage_`, `stage__result`, `last_save`, `done`, `results`, ordered by `Player.step_rank`. **Gotcha:** stage reach is computed from `progress`, *not* by comparing `furthest_step` ranks. The last-save early exit jumps a player straight to the end of the game, so a rank comparison would credit them with stages they never saw. The last-save branch is therefore reported as its own panel rather than as a funnel step. **Gotcha:** `DemoActivity` (see `app/services/demo_activity.rb`) invents players with no gameplay to feed the leaderboard banner, which inflates every count and shows up as a huge drop before "Facts". The dashboard warns about this while `DemoActivity::ENABLED` is true. ### Thumbs up / down `Player#rate!` stores `rating` as `1` / `-1` (`Player::RATINGS`), one per player — voting again overwrites. The results screen posts to `game#rate` via `rating_controller.js`, which flips the button state immediately and does not await the response. ## Project Structure - `app/controllers/admin/`: Admin backend logic. - `app/controllers/game_controller.rb`: Main game loop (stage -> answer -> result -> results). - `app/controllers/api/`: JSON endpoints (see `docs/leaderboard_api.md`). - `app/helpers/game_helper.rb`: Result bands and per-impact tone thresholds. - `app/services/game_analytics.rb`: Aggregates the admin analytics dashboard. - `app/controllers/admin/analytics_controller.rb`: Admin analytics dashboard. - `app/models/concerns/`: Shared logic for ancestry, attachments, and tags. - `config/locales/`: YAML translation files. - `config/question_scores.json`: Answer scoring table.