Gamer Paperdoll Inventory Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the tier-based character on /gamer with a layered paperdoll figure, plus an inventory panel where the user equips/unequips helmet, armor, and legs items via Hotwire. Add a man/woman gender toggle on the profile edit page that drives the naked base layers and filters gendered hair items.
Architecture:
- A pure-Ruby
Paperdoll::Catalogreads PNGs fromapp/assets/images/gamer/paperdoll/{helmets,armors,legs}once at boot and exposes per-slot, gender-filtered item lists. - A
Paperdoll::Loadoutvalue-wrapper around a newusers.paperdoll_loadoutJSON column owns gender + equipped slugs and computes the layer stack. - A
PaperdollLoadoutControllerexposesPATCH /paperdoll_loadout/:slot/:slug(toggle action) and responds with three Turbo Stream replacements (top badge, player panel, inventory panel). - A shared
_paperdoll_figurepartial renders any size of the layered figure as absolutely-positioned<img>tags inside anaspect-[168/266]box.
Tech Stack: Rails 8.1, Hotwire (Turbo + Stimulus), Tailwind v4, Pundit, Devise, Minitest.
Spec: docs/superpowers/specs/2026-05-06-gamer-paperdoll-inventory-design.md
File Structure
New files:
| Path | Responsibility |
|---|---|
db/migrate/<ts>_add_paperdoll_loadout_to_users.rb |
Adds paperdoll_loadout :json, default: {}, null: false. |
app/models/paperdoll/item.rb |
Value object: slot, slug, filename, gender, asset_path, display_name. |
app/models/paperdoll/catalog.rb |
Reads PNGs at boot; exposes items_for(slot, gender:), naked_asset_path(slot, gender:), find(slot, slug). |
app/models/paperdoll/loadout.rb |
Wraps user.paperdoll_loadout; exposes gender, equipped, equip, unequip, toggle, set_gender, layers. |
app/policies/paperdoll_loadout_policy.rb |
toggle? requires authenticated user. |
app/controllers/paperdoll_loadout_controller.rb |
toggle action; turbo_stream + html responses. |
app/views/paperdoll_loadout/toggle.turbo_stream.erb |
Replaces the three frames. |
app/views/gamer_dashboard/_paperdoll_figure.html.erb |
Layered figure partial; takes layers: and size_class:. |
app/views/gamer_dashboard/_inventory_panel.html.erb |
Inventory panel: preview + slot sections + equip buttons. |
app/javascript/controllers/paperdoll_inventory_controller.js |
Optimistic equip highlight on click. |
test/models/paperdoll/item_test.rb, catalog_test.rb, loadout_test.rb |
Model unit tests. |
test/controllers/paperdoll_loadout_controller_test.rb |
Toggle action tests. |
test/system/gamer_dashboard/paperdoll_equip_test.rb |
End-to-end click-to-equip system test. |
Modified files:
| Path | Change |
|---|---|
config/routes.rb |
Add resource :paperdoll_loadout block. |
app/views/gamer_dashboard/show.html.erb |
Top badge → paperdoll figure inside turbo frame; insert inventory_panel between player and habits. |
app/views/gamer_dashboard/_player_panel.html.erb |
image_tag character_asset_path → paperdoll figure inside turbo frame. |
app/models/gamer_dashboard/snapshot.rb |
Add paperdoll_loadout and paperdoll_layers. |
app/views/devise/registrations/edit.html.erb |
Add Character section with man/woman toggle. |
app/controllers/users/registrations_controller.rb |
Set @paperdoll_gender in edit; call save_gender from update. |
test/controllers/users/registrations_controller_test.rb |
New tests for gender persistence (file may not exist; create if missing). |
config/locales/en.yml, config/locales/pt-BR.yml |
New keys under gamer_dashboard.inventory.* and user_profile.character_section/gender_man/gender_woman. |
Task 1: Migration — add paperdoll_loadout JSON column to users
Files:
-
Create:
db/migrate/<TIMESTAMP>_add_paperdoll_loadout_to_users.rb -
Step 1: Generate the migration
bin/rails generate migration AddPaperdollLoadoutToUsers paperdoll_loadout:json
- Step 2: Edit the generated migration
Open the new file in db/migrate/ and replace its body with:
class AddPaperdollLoadoutToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :paperdoll_loadout, :json, default: {}, null: false
end
end
- Step 3: Run migration
bin/rails db:migrate
Expected: add_column(:users, :paperdoll_loadout, :json, ...) runs and prints "AddPaperdollLoadoutToUsers: migrated".
- Step 4: Annotate the User model
bundle exec annotaterb models --models user
The schema comment block at the top of app/models/user.rb should now include paperdoll_loadout :json default({}), not null.
- Step 5: Commit
git add db/migrate db/schema.rb app/models/user.rb
git commit -m "Add paperdoll_loadout JSON column to users"
Task 2: Paperdoll::Item value object
Files:
- Create:
app/models/paperdoll/item.rb -
Test:
test/models/paperdoll/item_test.rb - Step 1: Write the failing tests
test/models/paperdoll/item_test.rb:
require "test_helper"
class Paperdoll::ItemTest < ActiveSupport::TestCase
test "asset_path joins slot directory and filename" do
item = Paperdoll::Item.new(slot: :helmet, slug: "helmet-3", filename: "helmet-3.png", gender: nil)
assert_equal "gamer/paperdoll/helmets/helmet-3.png", item.asset_path
end
test "asset_path uses correct directory for legs" do
item = Paperdoll::Item.new(slot: :legs, slug: "legs-2", filename: "legs-2.png", gender: nil)
assert_equal "gamer/paperdoll/legs/legs-2.png", item.asset_path
end
test "asset_path uses 'armors' for armor slot" do
item = Paperdoll::Item.new(slot: :armor, slug: "armor-1", filename: "armor-1.png", gender: nil)
assert_equal "gamer/paperdoll/armors/armor-1.png", item.asset_path
end
test "display_name titleizes slug with hyphens replaced by spaces" do
item = Paperdoll::Item.new(slot: :helmet, slug: "hair-man-1", filename: "hair-man-1.png", gender: "man")
assert_equal "Hair Man 1", item.display_name
end
test "gendered? is true when gender present" do
item = Paperdoll::Item.new(slot: :helmet, slug: "hair-man-1", filename: "hair-man-1.png", gender: "man")
assert_predicate item, :gendered?
end
test "gendered? is false when gender nil" do
item = Paperdoll::Item.new(slot: :helmet, slug: "helmet-1", filename: "helmet-1.png", gender: nil)
assert_not_predicate item, :gendered?
end
end
- Step 2: Run the tests to verify they fail
bin/rails test test/models/paperdoll/item_test.rb
Expected: NameError: uninitialized constant Paperdoll.
- Step 3: Implement
Paperdoll::Item
app/models/paperdoll/item.rb:
module Paperdoll
Item = Data.define(:slot, :slug, :filename, :gender) do
SLOT_DIR = { helmet: "helmets", armor: "armors", legs: "legs" }.freeze
def asset_path
"gamer/paperdoll/#{SLOT_DIR.fetch(slot)}/#{filename}"
end
def display_name
slug.tr("-", " ").titleize
end
def gendered?
!gender.nil?
end
end
end
- Step 4: Run the tests to verify they pass
bin/rails test test/models/paperdoll/item_test.rb
Expected: 6 runs, 6 assertions, 0 failures.
- Step 5: Commit
git add app/models/paperdoll/item.rb test/models/paperdoll/item_test.rb
git commit -m "Add Paperdoll::Item value object"
Task 3: Paperdoll::Catalog
Files:
- Create:
app/models/paperdoll/catalog.rb -
Test:
test/models/paperdoll/catalog_test.rb - Step 1: Write the failing tests
test/models/paperdoll/catalog_test.rb:
require "test_helper"
class Paperdoll::CatalogTest < ActiveSupport::TestCase
test "SLOTS lists the three slots" do
assert_equal %i[helmet armor legs], Paperdoll::Catalog::SLOTS
end
test "items_for(:helmet, gender: 'man') includes unisex helmets and man-only hair" do
slugs = Paperdoll::Catalog.items_for(:helmet, gender: "man").map(&:slug)
assert_includes slugs, "helmet-1"
assert_includes slugs, "helmet-5"
assert_includes slugs, "hair-man-1"
assert_not_includes slugs, "hair-woman-1"
end
test "items_for(:helmet, gender: 'woman') includes unisex helmets and woman-only hair" do
slugs = Paperdoll::Catalog.items_for(:helmet, gender: "woman").map(&:slug)
assert_includes slugs, "helmet-1"
assert_includes slugs, "hair-woman-1"
assert_not_includes slugs, "hair-man-1"
end
test "items_for never returns naked bases" do
%i[helmet armor legs].each do |slot|
%w[man woman].each do |g|
slugs = Paperdoll::Catalog.items_for(slot, gender: g).map(&:slug)
assert_empty slugs.grep(/\Anaked-/), "naked-* leaked into #{slot}/#{g}"
end
end
end
test "items_for armor and legs are unisex (5 items each, both genders)" do
%w[man woman].each do |g|
assert_equal 5, Paperdoll::Catalog.items_for(:armor, gender: g).size
assert_equal 5, Paperdoll::Catalog.items_for(:legs, gender: g).size
end
end
test "naked_asset_path returns the correct gender-suffixed path" do
assert_equal "gamer/paperdoll/helmets/naked-helmet-1-man.png",
Paperdoll::Catalog.naked_asset_path(:helmet, gender: "man")
assert_equal "gamer/paperdoll/armors/naked-armor-1-woman.png",
Paperdoll::Catalog.naked_asset_path(:armor, gender: "woman")
assert_equal "gamer/paperdoll/legs/naked-legs-1-woman.png",
Paperdoll::Catalog.naked_asset_path(:legs, gender: "woman")
end
test "find returns an Item when slug exists" do
item = Paperdoll::Catalog.find(:helmet, "helmet-3")
assert_kind_of Paperdoll::Item, item
assert_equal "helmet-3", item.slug
assert_nil item.gender
end
test "find returns the gendered item correctly" do
item = Paperdoll::Catalog.find(:helmet, "hair-man-2")
assert_equal "man", item.gender
end
test "find returns nil for unknown slug" do
assert_nil Paperdoll::Catalog.find(:helmet, "nope")
end
test "find returns nil for naked base slugs" do
assert_nil Paperdoll::Catalog.find(:helmet, "naked-helmet-1-man")
end
end
- Step 2: Run the tests to verify they fail
bin/rails test test/models/paperdoll/catalog_test.rb
Expected: NameError: uninitialized constant Paperdoll::Catalog.
- Step 3: Implement
Paperdoll::Catalog
app/models/paperdoll/catalog.rb:
module Paperdoll
module Catalog
SLOTS = %i[helmet armor legs].freeze
GENDERS = %w[man woman].freeze
SLOT_DIR = { helmet: "helmets", armor: "armors", legs: "legs" }.freeze
BASE_DIR = Rails.root.join("app/assets/images/gamer/paperdoll")
class << self
def items_for(slot, gender:)
all_items.fetch(slot).reject { |item| item.gendered? && item.gender != gender }
end
def naked_asset_path(slot, gender:)
"gamer/paperdoll/#{SLOT_DIR.fetch(slot)}/naked-#{slot}-1-#{gender}.png"
end
def find(slot, slug)
all_items.fetch(slot).find { |item| item.slug == slug }
end
private
def all_items
@all_items ||= SLOTS.each_with_object({}) do |slot, acc|
acc[slot] = scan_slot(slot).freeze
end.freeze
end
def scan_slot(slot)
Dir.children(BASE_DIR.join(SLOT_DIR.fetch(slot)))
.select { |f| f.end_with?(".png") }
.reject { |f| f.start_with?("naked-") }
.sort
.map { |filename| build_item(slot, filename) }
end
def build_item(slot, filename)
slug = File.basename(filename, ".png")
gender = GENDERS.find { |g| slug.end_with?("-#{g}") || slug.match?(/-#{g}-\d+\z/) }
Item.new(slot: slot, slug: slug, filename: filename, gender: gender)
end
end
end
end
Note: the gender regex matches both hair-man-1 (gender token before trailing number) and any other -man/-woman suffix patterns. Verify with the test suite.
- Step 4: Run the tests to verify they pass
bin/rails test test/models/paperdoll/catalog_test.rb
Expected: 10 runs, all pass.
- Step 5: Commit
git add app/models/paperdoll/catalog.rb test/models/paperdoll/catalog_test.rb
git commit -m "Add Paperdoll::Catalog filesystem-driven item registry"
Task 4: Paperdoll::Loadout
Files:
- Create:
app/models/paperdoll/loadout.rb -
Test:
test/models/paperdoll/loadout_test.rb - Step 1: Write the failing tests
test/models/paperdoll/loadout_test.rb:
require "test_helper"
class Paperdoll::LoadoutTest < ActiveSupport::TestCase
setup do
@user = users(:admin)
@user.update!(paperdoll_loadout: {})
end
test "default gender is 'man' when JSON empty" do
assert_equal "man", Paperdoll::Loadout.new(@user).gender
end
test "equip persists slug for slot" do
Paperdoll::Loadout.new(@user).equip(:helmet, "helmet-3")
assert_equal "helmet-3", @user.reload.paperdoll_loadout["helmet"]
end
test "equip raises ArgumentError on unknown slot" do
assert_raises(ArgumentError) { Paperdoll::Loadout.new(@user).equip(:cape, "helmet-3") }
assert_equal({}, @user.reload.paperdoll_loadout)
end
test "equip raises ArgumentError on unknown slug" do
assert_raises(ArgumentError) { Paperdoll::Loadout.new(@user).equip(:helmet, "bogus") }
assert_equal({}, @user.reload.paperdoll_loadout)
end
test "equipped returns Item for current slug" do
Paperdoll::Loadout.new(@user).equip(:helmet, "helmet-3")
item = Paperdoll::Loadout.new(@user).equipped(:helmet)
assert_equal "helmet-3", item.slug
end
test "equipped returns nil when slot empty" do
assert_nil Paperdoll::Loadout.new(@user).equipped(:helmet)
end
test "unequip clears slot" do
loadout = Paperdoll::Loadout.new(@user)
loadout.equip(:helmet, "helmet-3")
loadout.unequip(:helmet)
assert_nil @user.reload.paperdoll_loadout["helmet"]
end
test "toggle equips when slot empty" do
Paperdoll::Loadout.new(@user).toggle(:helmet, "helmet-3")
assert_equal "helmet-3", @user.reload.paperdoll_loadout["helmet"]
end
test "toggle unequips when same slug already equipped" do
loadout = Paperdoll::Loadout.new(@user)
loadout.equip(:helmet, "helmet-3")
loadout.toggle(:helmet, "helmet-3")
assert_nil @user.reload.paperdoll_loadout["helmet"]
end
test "toggle replaces when different slug equipped" do
loadout = Paperdoll::Loadout.new(@user)
loadout.equip(:helmet, "helmet-1")
loadout.toggle(:helmet, "helmet-3")
assert_equal "helmet-3", @user.reload.paperdoll_loadout["helmet"]
end
test "set_gender persists new gender" do
Paperdoll::Loadout.new(@user).set_gender("woman")
assert_equal "woman", @user.reload.paperdoll_loadout["gender"]
end
test "set_gender prunes gendered items that no longer match" do
@user.update!(paperdoll_loadout: { "gender" => "man", "helmet" => "hair-man-1" })
Paperdoll::Loadout.new(@user).set_gender("woman")
loadout = @user.reload.paperdoll_loadout
assert_equal "woman", loadout["gender"]
assert_nil loadout["helmet"]
end
test "set_gender keeps unisex items equipped" do
@user.update!(paperdoll_loadout: { "gender" => "man", "helmet" => "helmet-3", "armor" => "armor-2" })
Paperdoll::Loadout.new(@user).set_gender("woman")
loadout = @user.reload.paperdoll_loadout
assert_equal "helmet-3", loadout["helmet"]
assert_equal "armor-2", loadout["armor"]
end
test "set_gender raises on invalid value and does not persist" do
assert_raises(ArgumentError) { Paperdoll::Loadout.new(@user).set_gender("alien") }
assert_nil @user.reload.paperdoll_loadout["gender"]
end
test "layers default to all-naked man bases" do
layers = Paperdoll::Loadout.new(@user).layers
assert_equal [
"gamer/paperdoll/helmets/naked-helmet-1-man.png",
"gamer/paperdoll/armors/naked-armor-1-man.png",
"gamer/paperdoll/legs/naked-legs-1-man.png"
], layers
end
test "layers stack helmet on top of naked base" do
Paperdoll::Loadout.new(@user).equip(:helmet, "helmet-3")
layers = Paperdoll::Loadout.new(@user).layers
assert_equal [
"gamer/paperdoll/helmets/naked-helmet-1-man.png",
"gamer/paperdoll/helmets/helmet-3.png",
"gamer/paperdoll/armors/naked-armor-1-man.png",
"gamer/paperdoll/legs/naked-legs-1-man.png"
], layers
end
test "layers replace naked armor when armor equipped" do
Paperdoll::Loadout.new(@user).equip(:armor, "armor-2")
layers = Paperdoll::Loadout.new(@user).layers
assert_includes layers, "gamer/paperdoll/armors/armor-2.png"
assert_not_includes layers, "gamer/paperdoll/armors/naked-armor-1-man.png"
end
test "layers swap naked bases by gender" do
Paperdoll::Loadout.new(@user).set_gender("woman")
layers = Paperdoll::Loadout.new(@user).layers
assert_includes layers, "gamer/paperdoll/helmets/naked-helmet-1-woman.png"
assert_includes layers, "gamer/paperdoll/armors/naked-armor-1-woman.png"
assert_includes layers, "gamer/paperdoll/legs/naked-legs-1-woman.png"
end
end
- Step 2: Run the tests to verify they fail
bin/rails test test/models/paperdoll/loadout_test.rb
Expected: NameError: uninitialized constant Paperdoll::Loadout.
- Step 3: Implement
Paperdoll::Loadout
app/models/paperdoll/loadout.rb:
module Paperdoll
class Loadout
DEFAULT_GENDER = "man".freeze
def initialize(user)
@user = user
end
def gender
data["gender"].presence || DEFAULT_GENDER
end
def equipped(slot)
slug = data[slot.to_s]
return nil if slug.blank?
Catalog.find(slot.to_sym, slug)
end
def equip(slot, slug)
validate_slot!(slot)
raise ArgumentError, "unknown slug: #{slug}" unless Catalog.find(slot.to_sym, slug)
write(slot.to_s => slug)
end
def unequip(slot)
validate_slot!(slot)
write(slot.to_s => nil)
end
def toggle(slot, slug)
current = data[slot.to_s]
current == slug ? unequip(slot) : equip(slot, slug)
end
def set_gender(new_gender)
raise ArgumentError, "invalid gender: #{new_gender}" unless Catalog::GENDERS.include?(new_gender)
pruned = Catalog::SLOTS.each_with_object({}) do |slot, acc|
slug = data[slot.to_s]
next if slug.blank?
item = Catalog.find(slot, slug)
acc[slot.to_s] = slug if item && (!item.gendered? || item.gender == new_gender)
end
replace(pruned.merge("gender" => new_gender))
end
def layers
g = gender
result = [Catalog.naked_asset_path(:helmet, gender: g)]
result << equipped(:helmet)&.asset_path
result << (equipped(:armor)&.asset_path || Catalog.naked_asset_path(:armor, gender: g))
result << (equipped(:legs)&.asset_path || Catalog.naked_asset_path(:legs, gender: g))
result.compact
end
private
attr_reader :user
def data
user.paperdoll_loadout || {}
end
def validate_slot!(slot)
raise ArgumentError, "unknown slot: #{slot}" unless Catalog::SLOTS.include?(slot.to_sym)
end
def write(changes)
user.update!(paperdoll_loadout: data.merge(changes))
end
def replace(new_data)
user.update!(paperdoll_loadout: new_data)
end
end
end
- Step 4: Run the tests to verify they pass
bin/rails test test/models/paperdoll/loadout_test.rb
Expected: all loadout tests pass.
- Step 5: Commit
git add app/models/paperdoll/loadout.rb test/models/paperdoll/loadout_test.rb
git commit -m "Add Paperdoll::Loadout user-state wrapper"
Task 5: Snapshot integration
Files:
- Modify:
app/models/gamer_dashboard/snapshot.rb -
Test:
test/models/gamer_dashboard/snapshot_test.rb - Step 1: Write the failing test
Append to test/models/gamer_dashboard/snapshot_test.rb:
test "paperdoll_layers delegates to user's loadout" do
user = users(:admin)
user.update!(paperdoll_loadout: { "gender" => "man", "helmet" => "helmet-3" })
snapshot = GamerDashboard::Snapshot.new(user)
assert_equal Paperdoll::Loadout.new(user).layers, snapshot.paperdoll_layers
assert_includes snapshot.paperdoll_layers, "gamer/paperdoll/helmets/helmet-3.png"
end
test "paperdoll_loadout returns a Paperdoll::Loadout instance" do
user = users(:admin)
snapshot = GamerDashboard::Snapshot.new(user)
assert_kind_of Paperdoll::Loadout, snapshot.paperdoll_loadout
end
- Step 2: Run the test to verify it fails
bin/rails test test/models/gamer_dashboard/snapshot_test.rb -n /paperdoll/
Expected: NoMethodError: undefined method 'paperdoll_layers'.
- Step 3: Add the methods to
Snapshot
In app/models/gamer_dashboard/snapshot.rb, add after character_asset_path (around line 47):
def paperdoll_loadout
@paperdoll_loadout ||= Paperdoll::Loadout.new(user)
end
def paperdoll_layers
paperdoll_loadout.layers
end
- Step 4: Run the test to verify it passes
bin/rails test test/models/gamer_dashboard/snapshot_test.rb -n /paperdoll/
Expected: 2 runs, 0 failures.
- Step 5: Commit
git add app/models/gamer_dashboard/snapshot.rb test/models/gamer_dashboard/snapshot_test.rb
git commit -m "Expose paperdoll layers from GamerDashboard::Snapshot"
Task 6: Routes + Pundit policy + controller
Files:
- Modify:
config/routes.rb - Create:
app/policies/paperdoll_loadout_policy.rb - Create:
app/controllers/paperdoll_loadout_controller.rb - Create:
app/views/paperdoll_loadout/toggle.turbo_stream.erb(placeholder; full content in Task 9 once partials exist) -
Test:
test/controllers/paperdoll_loadout_controller_test.rb - Step 1: Add the route
In config/routes.rb, immediately after the get "gamer", to: "gamer_dashboard#show" line (around line 82):
resource :paperdoll_loadout, only: [], controller: "paperdoll_loadout" do
patch ":slot/:slug",
action: :toggle,
as: :toggle,
constraints: { slot: /helmet|armor|legs/, slug: /[a-z0-9\-]+/ }
end
- Step 2: Verify the route resolves
bin/rails routes -c paperdoll_loadout
Expected output includes:
toggle_paperdoll_loadout PATCH /paperdoll_loadout/:slot/:slug(.:format) paperdoll_loadout#toggle
- Step 3: Create the Pundit policy
app/policies/paperdoll_loadout_policy.rb:
class PaperdollLoadoutPolicy < ApplicationPolicy
def toggle? = user.present?
end
- Step 4: Write the failing controller test
test/controllers/paperdoll_loadout_controller_test.rb:
require "test_helper"
class PaperdollLoadoutControllerTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
setup do
@user = users(:admin)
@user.update!(paperdoll_loadout: {})
end
test "unauthenticated request redirects to sign in" do
patch toggle_paperdoll_loadout_path(slot: "helmet", slug: "helmet-3")
assert_response :redirect
assert_redirected_to new_user_session_path
end
test "authenticated patch equips item and returns turbo_stream" do
sign_in @user
patch toggle_paperdoll_loadout_path(slot: "helmet", slug: "helmet-3"),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_equal "helmet-3", @user.reload.paperdoll_loadout["helmet"]
assert_match "turbo-stream", response.media_type
end
test "authenticated patch toggles off when same item equipped" do
@user.update!(paperdoll_loadout: { "helmet" => "helmet-3" })
sign_in @user
patch toggle_paperdoll_loadout_path(slot: "helmet", slug: "helmet-3")
assert_nil @user.reload.paperdoll_loadout["helmet"]
end
test "html format redirects back to gamer dashboard" do
sign_in @user
patch toggle_paperdoll_loadout_path(slot: "helmet", slug: "helmet-3")
assert_redirected_to gamer_path
end
test "unknown slot returns 404" do
sign_in @user
patch "/paperdoll_loadout/cape/anything"
assert_response :not_found
end
test "unknown slug renders 422" do
sign_in @user
patch toggle_paperdoll_loadout_path(slot: "helmet", slug: "bogus"),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :unprocessable_entity
end
end
Note: the routing-error test uses raw patch "/paperdoll_loadout/cape/anything" because the named helper would not generate a path that fails the route constraint. If the routing test raises in production it returns a 404 page; in test mode the exception escapes. Adjust to assert_response :not_found if your test config catches routing errors.
- Step 5: Implement the controller
app/controllers/paperdoll_loadout_controller.rb:
class PaperdollLoadoutController < ApplicationController
rescue_from ArgumentError, with: :render_invalid
def toggle
authorize :paperdoll_loadout
Paperdoll::Loadout.new(current_user).toggle(params[:slot].to_sym, params[:slug])
@snapshot = GamerDashboard::Snapshot.new(current_user)
respond_to do |format|
format.turbo_stream
format.html { redirect_to gamer_path }
end
end
private
def render_invalid
respond_to do |format|
format.turbo_stream { head :unprocessable_entity }
format.html { redirect_to gamer_path, alert: "Unknown item." }
end
end
end
- Step 6: Create a placeholder turbo_stream view
app/views/paperdoll_loadout/toggle.turbo_stream.erb:
<%# Replaced with full content in Task 9 once paperdoll_figure and inventory_panel partials exist. %>
<%= turbo_stream.replace "paperdoll_top_badge" do %>
<%= turbo_frame_tag "paperdoll_top_badge" %>
<% end %>
- Step 7: Run controller tests to verify they pass
bin/rails test test/controllers/paperdoll_loadout_controller_test.rb
Expected: 6 runs, all pass. (The toggle/equip/unequip behavior tests pass because the controller delegates to Loadout, which we already tested.)
- Step 8: Commit
git add config/routes.rb \
app/policies/paperdoll_loadout_policy.rb \
app/controllers/paperdoll_loadout_controller.rb \
app/views/paperdoll_loadout/toggle.turbo_stream.erb \
test/controllers/paperdoll_loadout_controller_test.rb
git commit -m "Add PaperdollLoadoutController#toggle"
Task 7: Shared _paperdoll_figure partial
Files:
-
Create:
app/views/gamer_dashboard/_paperdoll_figure.html.erb -
Step 1: Create the partial
app/views/gamer_dashboard/_paperdoll_figure.html.erb:
<%# Locals:
layers: Array<String> — asset paths in stacking order (bottom → top)
size_class: String — Tailwind height class, e.g. "h-8" / "h-24" / "h-40"
%>
<div class="<%= size_class %> aspect-[168/266] relative">
<% layers.each do |path| %>
<%= image_tag path,
class: "absolute inset-0 w-full h-full object-contain image-render-pixel",
alt: "" %>
<% end %>
</div>
- Step 2: Commit the partial
The partial has no consumer yet — Task 8 wires it into views and adds assertions. No test runs at this step.
git add app/views/gamer_dashboard/_paperdoll_figure.html.erb
git commit -m "Add _paperdoll_figure shared partial"
Task 8: Replace top badge and player panel character with paperdoll
Files:
- Modify:
app/views/gamer_dashboard/show.html.erb -
Modify:
app/views/gamer_dashboard/_player_panel.html.erb - Step 1: Replace the top-right badge in
show.html.erb
In app/views/gamer_dashboard/show.html.erb, find lines 9-12:
<%= link_to gamification_path, class: "gamer-top-level inline-flex items-center gap-2" do %>
<%= image_tag @snapshot.character_asset_path, class: "size-8 object-contain image-render-pixel", alt: "" %>
<span><%= t("gamer_dashboard.level", level: @snapshot.level_progress[:level]) %></span>
<% end %>
Replace with:
<%= link_to gamification_path, class: "gamer-top-level inline-flex items-center gap-2" do %>
<%= turbo_frame_tag "paperdoll_top_badge" do %>
<%= render "paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-8" %>
<% end %>
<span><%= t("gamer_dashboard.level", level: @snapshot.level_progress[:level]) %></span>
<% end %>
- Step 2: Replace the player panel character
In app/views/gamer_dashboard/_player_panel.html.erb, find lines 3-5:
<div class="gamer-avatar-frame">
<%= image_tag snapshot.character_asset_path, class: "size-24 object-contain image-render-pixel", alt: "" %>
</div>
Replace with:
<div class="gamer-avatar-frame">
<%= turbo_frame_tag "paperdoll_player_panel" do %>
<%= render "paperdoll_figure", layers: snapshot.paperdoll_layers, size_class: "h-24" %>
<% end %>
</div>
- Step 3: Add an assertion to the gamer dashboard controller test and run it
In test/controllers/gamer_dashboard_controller_test.rb, append to the existing test "authenticated user can access gamer dashboard":
assert_select "img[src*='naked-helmet-1-man.png']"
Run:
bin/rails test test/controllers/gamer_dashboard_controller_test.rb
Expected: all tests pass (the partial is now wired into both the top badge and player panel, so the naked base image is in the response body).
- Step 4: Manual visual check
Start the dev server and visit /gamer:
bin/dev
Confirm the small badge in the top-right and the player-panel avatar both show the layered paperdoll (naked man base, with no equipped items). The figure should be rectangular, not square; pixel rendering should remain crisp.
- Step 5: Commit
git add app/views/gamer_dashboard/show.html.erb \
app/views/gamer_dashboard/_player_panel.html.erb \
test/controllers/gamer_dashboard_controller_test.rb
git commit -m "Render layered paperdoll for top badge and player panel"
Task 9: Inventory panel partial + show wiring + full turbo_stream view
Files:
- Create:
app/views/gamer_dashboard/_inventory_panel.html.erb - Modify:
app/views/gamer_dashboard/show.html.erb - Modify:
app/views/paperdoll_loadout/toggle.turbo_stream.erb -
Modify:
config/locales/en.yml,config/locales/pt-BR.yml - Step 1: Add locale keys (English)
In config/locales/en.yml, find the gamer_dashboard: block (line 285) and append after the existing keys (before the next top-level key) — insert under the gamer_dashboard: key after no_activities:
inventory:
title: Inventory
helmets: Helmets
armors: Armors
legs: Legs
empty: No items in this slot.
- Step 2: Add locale keys (Portuguese)
In config/locales/pt-BR.yml, find the matching gamer_dashboard: block and add the same inventory: sub-block with translations:
inventory:
title: Inventário
helmets: Capacetes
armors: Armaduras
legs: Pernas
empty: Nenhum item neste slot.
- Step 3: Create the inventory panel partial
app/views/gamer_dashboard/_inventory_panel.html.erb:
<%# Locals: snapshot %>
<%= turbo_frame_tag "paperdoll_inventory" do %>
<div class="gamer-panel p-4 space-y-4" data-controller="paperdoll-inventory">
<div class="flex items-center justify-between">
<h3 class="text-[12px] font-bold uppercase tracking-wide text-slate-300">
<%= t("gamer_dashboard.inventory.title") %>
</h3>
</div>
<div class="flex justify-center">
<div class="gamer-avatar-frame">
<%= render "paperdoll_figure", layers: snapshot.paperdoll_layers, size_class: "h-40" %>
</div>
</div>
<% Paperdoll::Catalog::SLOTS.each do |slot| %>
<% items = Paperdoll::Catalog.items_for(slot, gender: snapshot.paperdoll_loadout.gender) %>
<% equipped_slug = snapshot.paperdoll_loadout.equipped(slot)&.slug %>
<div data-paperdoll-inventory-target="slot" data-slot="<%= slot %>">
<p class="mb-2 text-[11px] font-bold uppercase text-slate-500">
<%= t("gamer_dashboard.inventory.#{slot.to_s.pluralize}") %>
</p>
<% if items.empty? %>
<p class="text-[11px] text-slate-500"><%= t("gamer_dashboard.inventory.empty") %></p>
<% else %>
<div class="grid grid-cols-5 gap-2">
<% items.each do |item| %>
<%= button_to toggle_paperdoll_loadout_path(slot: slot, slug: item.slug),
method: :patch,
form: { data: { turbo_frame: "paperdoll_inventory" } },
class: "block aspect-[168/266] w-full overflow-hidden rounded border " \
"#{equipped_slug == item.slug ? 'border-amber-300 ring-2 ring-amber-300' : 'border-slate-700 hover:border-slate-500'} " \
"bg-slate-900/40 p-1 transition-colors",
title: item.display_name,
data: {
paperdoll_inventory_target: "item",
slot: slot,
slug: item.slug,
action: "click->paperdoll-inventory#mark"
} do %>
<%= image_tag item.asset_path,
class: "w-full h-full object-contain image-render-pixel",
alt: item.display_name %>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
- Step 4: Render the inventory panel from
show.html.erb
In app/views/gamer_dashboard/show.html.erb, find the left aside (around line 16-19):
<aside class="space-y-3">
<%= render "player_panel", snapshot: @snapshot %>
<%= render "habits_panel", snapshot: @snapshot %>
</aside>
Replace with:
<aside class="space-y-3">
<%= render "player_panel", snapshot: @snapshot %>
<%= render "inventory_panel", snapshot: @snapshot %>
<%= render "habits_panel", snapshot: @snapshot %>
</aside>
- Step 5: Replace the placeholder turbo_stream view with the full one
app/views/paperdoll_loadout/toggle.turbo_stream.erb — overwrite with:
<%= turbo_stream.replace "paperdoll_top_badge" do %>
<%= turbo_frame_tag "paperdoll_top_badge" do %>
<%= render "gamer_dashboard/paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-8" %>
<% end %>
<% end %>
<%= turbo_stream.replace "paperdoll_player_panel" do %>
<%= turbo_frame_tag "paperdoll_player_panel" do %>
<%= render "gamer_dashboard/paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-24" %>
<% end %>
<% end %>
<%= turbo_stream.replace "paperdoll_inventory" do %>
<%= render "gamer_dashboard/inventory_panel", snapshot: @snapshot %>
<% end %>
- Step 6: Run the gamer dashboard controller test and add an inventory assertion
Update the existing authenticated test in test/controllers/gamer_dashboard_controller_test.rb. After the existing assertions, add:
assert_select "turbo-frame#paperdoll_inventory"
assert_includes response.body, I18n.t("gamer_dashboard.inventory.title")
Run:
bin/rails test test/controllers/gamer_dashboard_controller_test.rb
Expected: all pass.
- Step 7: Manual check
Visit /gamer. Confirm:
- Inventory panel appears in the left aside between Player Panel and Habits Panel.
- A larger paperdoll preview shows at the top of the panel (h-40 ≈ 160px tall).
- Three slot sections (Helmets / Armors / Legs) each show their items as small thumbs.
- Clicking a helmet equips it (figure updates, button highlights).
- Clicking the same helmet again unequips it.
-
The top-right badge and player panel avatar update in lockstep.
- Step 8: Commit
git add app/views/gamer_dashboard/_inventory_panel.html.erb \
app/views/gamer_dashboard/show.html.erb \
app/views/paperdoll_loadout/toggle.turbo_stream.erb \
config/locales/en.yml config/locales/pt-BR.yml \
test/controllers/gamer_dashboard_controller_test.rb
git commit -m "Add inventory panel and three-frame turbo_stream toggle"
Task 10: Stimulus controller for optimistic equip highlight
Files:
-
Create:
app/javascript/controllers/paperdoll_inventory_controller.js -
Step 1: Create the Stimulus controller
app/javascript/controllers/paperdoll_inventory_controller.js:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["slot", "item"]
mark(event) {
const button = event.currentTarget
const slot = button.dataset.slot
const wasEquipped = button.classList.contains("ring-amber-300")
this.itemTargets
.filter(t => t.dataset.slot === slot)
.forEach(t => t.classList.remove("border-amber-300", "ring-2", "ring-amber-300"))
if (!wasEquipped) {
button.classList.add("border-amber-300", "ring-2", "ring-amber-300")
}
}
}
The controller is auto-registered because controllers/index.js calls eagerLoadControllersFrom("controllers", application). No manual registration needed.
- Step 2: Manual smoke test
Restart bin/dev if it's running. On /gamer, click an inventory item: the optimistic highlight should appear immediately, then the Turbo Stream response from the server confirms the authoritative state. Click the same item again: the highlight clears.
- Step 3: Commit
git add app/javascript/controllers/paperdoll_inventory_controller.js
git commit -m "Add paperdoll-inventory Stimulus controller for optimistic equip"
Task 11: Profile edit — gender toggle
Files:
- Modify:
app/views/devise/registrations/edit.html.erb - Modify:
app/controllers/users/registrations_controller.rb - Modify:
config/locales/en.yml,config/locales/pt-BR.yml -
Test:
test/controllers/users/registrations_controller_test.rb(create if missing) - Step 1: Add locale keys (English)
In config/locales/en.yml, find user_profile: (line 2062). Add these three keys (alphabetically near change_photo):
character_section: Character
gender_man: Man
gender_woman: Woman
- Step 2: Add locale keys (Portuguese)
In config/locales/pt-BR.yml, add the matching translations:
character_section: Personagem
gender_man: Homem
gender_woman: Mulher
- Step 3: Modify the registrations controller
In app/controllers/users/registrations_controller.rb:
Replace the existing edit method:
def edit
@display_name = display_name_for(current_user)
super
end
with:
def edit
@display_name = display_name_for(current_user)
@paperdoll_gender = Paperdoll::Loadout.new(current_user).gender
super
end
Replace the existing update method:
def update
save_display_name
save_avatar
super
end
with:
def update
save_display_name
save_avatar
save_gender
super
end
Add a new private method below save_avatar:
def save_gender
return unless %w[man woman].include?(params[:gender])
Paperdoll::Loadout.new(current_user).set_gender(params[:gender])
end
- Step 4: Add the Character section to the edit view
In app/views/devise/registrations/edit.html.erb, find the closing </div> of the ===== 1. AVATAR & PERFIL ===== section (around line 92, just before the ===== 2. INFORMAÇÕES DA CONTA ===== block).
Insert this section between them:
<%# ===== CHARACTER ===== %>
<div class="rounded-xl p-6 bg-white dark:bg-[#161b22] border border-gray-200/60 dark:border-white/[0.06]">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
<div class="w-8 h-8 rounded-lg flex items-center justify-center bg-amber-500/15">
<svg class="size-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<%= t("user_profile.character_section") %>
</h3>
<div class="inline-flex rounded-lg overflow-hidden border border-gray-200 dark:border-white/10">
<% %w[man woman].each do |g| %>
<% selected = (@paperdoll_gender == g) %>
<label class="px-4 py-2 text-sm cursor-pointer transition-colors <%= selected ? 'bg-blue-500/15 text-blue-500' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.04]' %>">
<input type="radio" name="gender" value="<%= g %>" <%= 'checked' if selected %> class="sr-only">
<%= t("user_profile.gender_#{g}") %>
</label>
<% end %>
</div>
</div>
- Step 5: Write the failing controller tests
Create test/controllers/users/registrations_controller_test.rb if it does not exist:
require "test_helper"
class Users::RegistrationsControllerTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
setup do
@user = users(:admin)
@user.update!(paperdoll_loadout: {})
sign_in @user
end
test "update with gender=woman persists into paperdoll_loadout" do
put user_registration_path, params: { user: { current_password: "" }, gender: "woman" }
assert_equal "woman", @user.reload.paperdoll_loadout["gender"]
end
test "update with invalid gender is ignored" do
@user.update!(paperdoll_loadout: { "gender" => "man" })
put user_registration_path, params: { user: { current_password: "" }, gender: "alien" }
assert_equal "man", @user.reload.paperdoll_loadout["gender"]
end
test "switching to woman prunes hair-man items" do
@user.update!(paperdoll_loadout: { "gender" => "man", "helmet" => "hair-man-1" })
put user_registration_path, params: { user: { current_password: "" }, gender: "woman" }
loadout = @user.reload.paperdoll_loadout
assert_equal "woman", loadout["gender"]
assert_nil loadout["helmet"]
end
test "switching gender keeps unisex items equipped" do
@user.update!(paperdoll_loadout: { "gender" => "man", "helmet" => "helmet-3", "armor" => "armor-2" })
put user_registration_path, params: { user: { current_password: "" }, gender: "woman" }
loadout = @user.reload.paperdoll_loadout
assert_equal "helmet-3", loadout["helmet"]
assert_equal "armor-2", loadout["armor"]
end
end
- Step 6: Run the registration controller tests
bin/rails test test/controllers/users/registrations_controller_test.rb
Expected: 4 runs, all pass.
- Step 7: Manual check
Visit /users/edit. Confirm the Character section appears between Profile and Account Info, with Man/Woman toggle. Selecting Woman and clicking Save Changes should persist; reloading /gamer should now show the woman-base paperdoll.
- Step 8: Commit
git add app/views/devise/registrations/edit.html.erb \
app/controllers/users/registrations_controller.rb \
config/locales/en.yml config/locales/pt-BR.yml \
test/controllers/users/registrations_controller_test.rb
git commit -m "Add gender toggle to profile edit, persisted via Paperdoll::Loadout"
Task 12: System test — end-to-end equip flow
Files:
-
Test:
test/system/gamer_dashboard/paperdoll_equip_test.rb -
Step 1: Write the system test
test/system/gamer_dashboard/paperdoll_equip_test.rb:
require "application_system_test_case"
class GamerDashboard::PaperdollEquipTest < ApplicationSystemTestCase
setup do
@user = users(:admin)
@user.update!(paperdoll_loadout: {})
sign_in @user
end
test "user equips and unequips a helmet without page reload" do
visit gamer_path
# Default state: naked man base only.
assert_selector "img[src*='naked-helmet-1-man.png']"
assert_no_selector "img[src*='/helmet-3.png']"
# Equip helmet-3.
within "[data-controller='paperdoll-inventory']" do
find("button[data-slug='helmet-3']").click
end
assert_selector "img[src*='/helmet-3.png']", wait: 2
assert_selector "[data-slug='helmet-3'].ring-amber-300"
# Click again to unequip.
within "[data-controller='paperdoll-inventory']" do
find("button[data-slug='helmet-3']").click
end
assert_no_selector "img[src*='/helmet-3.png']", wait: 2
assert_no_selector "[data-slug='helmet-3'].ring-amber-300"
end
test "switching gender via profile updates naked base on /gamer" do
@user.update!(paperdoll_loadout: { "gender" => "man" })
visit edit_user_registration_path
choose "Woman"
find("input[type='submit']").click
visit gamer_path
assert_selector "img[src*='naked-helmet-1-woman.png']"
end
end
- Step 2: Run the system test
bin/rails test:system test/system/gamer_dashboard/paperdoll_equip_test.rb
Expected: 2 runs, 0 failures. Browser should drive Selenium through the equip and gender flows.
- Step 3: Commit
git add test/system/gamer_dashboard/paperdoll_equip_test.rb
git commit -m "Add paperdoll equip system test"
Task 13: Final regression sweep
- Step 1: Run the full test suite
bin/rails test test/models test/controllers test/integration
bin/rails test:system
Expected: all green. If anything in the broader suite (e.g. accessible_views_test, view_rendering_test) breaks because of changes to _player_panel or show.html.erb, fix the test or the view to match — they are likely just asserting against the now-removed tier image.
- Step 2: Run rubocop
bin/rubocop -a
Expected: no offenses (or only auto-corrected). Review and commit the auto-corrects.
- Step 3: Run brakeman
bin/brakeman --no-pager -q
Expected: no new warnings.
- Step 4: Final commit (only if rubocop produced changes)
git add -u
git commit -m "Apply rubocop auto-corrections"
Self-review checklist (already completed by the planner)
- Spec coverage: every spec section maps to a task — migration (T1), Item (T2), Catalog (T3), Loadout (T4), Snapshot (T5), routes/controller/policy (T6), figure partial (T7), top-of-page integration (T8), inventory panel + turbo_stream + locales (T9), Stimulus (T10), gender toggle (T11), system test (T12), regression (T13).
- Placeholder scan: no TBD/TODO/"add appropriate X" remaining; every test step shows the test code; every implementation step shows the code.
- Type consistency:
Paperdoll::Item.new(slot:, slug:, filename:, gender:)is used identically across all tasks; method names (equip,unequip,toggle,set_gender,equipped,layers,gender) match between tests and implementations; turbo frame ids (paperdoll_top_badge,paperdoll_player_panel,paperdoll_inventory) match between view, layout, and turbo_stream view.