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::Catalog reads PNGs from app/assets/images/gamer/paperdoll/{helmets,armors,legs} once at boot and exposes per-slot, gender-filtered item lists.
  • A Paperdoll::Loadout value-wrapper around a new users.paperdoll_loadout JSON column owns gender + equipped slugs and computes the layer stack.
  • A PaperdollLoadoutController exposes PATCH /paperdoll_loadout/:slot/:slug (toggle action) and responds with three Turbo Stream replacements (top badge, player panel, inventory panel).
  • A shared _paperdoll_figure partial renders any size of the layered figure as absolutely-positioned <img> tags inside an aspect-[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.