Files
BreakEscape/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md
Claude 368b1b6e7a feat: Add critical implementation details based on review
Based on comprehensive codebase review, enhanced implementation plans with:

## Phase 3 Updates (Scenario Conversion):
- Complete bash script to convert all 26 scenarios to ERB structure
- Explicit list of 3 main scenarios (ceo_exfil, cybok_heist, biometric_breach)
- List of 23 test/demo scenarios for development
- Instructions to rename .json to .erb (actual ERB code added later in Phase 4)
- Preserves git history with mv commands
- Both automated script and manual alternatives provided

## Phase 9 Updates (CSRF Token Handling):
NEW Section 9.3: "Setup CSRF Token Injection"
- Critical security implementation for Rails CSRF protection
- Complete view template with <%= form_authenticity_token %>
- JavaScript config injection via window.breakEscapeConfig
- CSRF token validation and error handling
- Browser console testing procedures
- 5 common CSRF issues with solutions
- Fallback to meta tag if config missing
- Development vs production considerations

## Phase 9 Updates (Async Unlock with Loading UI):
ENHANCED Section 9.5: "Update Unlock Validation with Loading UI"
- New file: unlock-loading-ui.js with Phaser.js throbbing tint effect
- showUnlockLoading(): Blue pulsing animation during server validation
- clearUnlockLoading(): Green flash on success, red flash on failure
- Alternative spinner implementation provided
- Complete unlockTarget() rewrite with async/await server validation
- Loading UI shows during API call (~100-300ms)
- Graceful error handling with user feedback
- Updates for ALL lock types: pin, password, key, lockpick, biometric, bluetooth, RFID
- Minigame callback updates to pass attempt and method to server
- Testing mode fallback (DISABLE_SERVER_VALIDATION)
- Preserves all existing unlock logic after server validation

## Key Features:
- Addresses 2 critical risks from review (CSRF tokens, async validation)
- Solves scenario conversion gap (26 files → ERB structure)
- Maintains backward compatibility during migration
- Comprehensive troubleshooting guidance
- Production-ready security implementation

Total additions: ~600 lines of detailed implementation guidance
2025-11-20 15:25:25 +00:00

38 KiB

BreakEscape Rails Engine - Implementation Plan

Complete step-by-step guide with explicit commands


How to Use This Plan

  1. Follow phases in order - Each phase builds on previous ones
  2. Read the entire phase before starting - Understand what you're doing
  3. Execute commands exactly as written - Copy/paste to avoid errors
  4. Test after each phase - Don't proceed if tests fail
  5. Commit after each phase - Preserve working state
  6. Use mv, not cp - Move files to preserve git history

Prerequisites

Before starting Phase 1:

# Verify you're in the correct directory
cd /home/user/BreakEscape
pwd  # Should print: /home/user/BreakEscape

# Verify git status
git status  # Should be clean or have only expected changes

# Verify Ruby and Rails versions
ruby -v   # Should be 3.0+
rails -v  # Should be 7.0+

# Verify PostgreSQL is running (if testing locally)
psql --version  # Should show PostgreSQL 14+

# Create a checkpoint
git add -A
git commit -m "chore: Checkpoint before Rails Engine migration"
git push

# Create feature branch
git checkout -b rails-engine-migration

Phase 1: Setup Rails Engine Structure (Week 1, ~8 hours)

Objectives

  • Generate Rails Engine boilerplate
  • Configure engine settings
  • Set up gemspec and dependencies
  • Verify engine loads

1.1 Generate Rails Engine

# Generate mountable engine with isolated namespace
# --mountable: Creates engine that can be mounted in routes
# --skip-git: Don't create new git repo (we're already in one)
# --dummy-path: Location for test dummy app
rails plugin new . --mountable --skip-git --dummy-path=test/dummy

# This creates:
# - lib/break_escape/engine.rb
# - lib/break_escape/version.rb
# - app/ directory structure
# - config/routes.rb
# - test/ directory structure
# - break_escape.gemspec

Expected output: Files created successfully

1.2 Configure Engine

Edit the generated engine file:

# Open engine file
vim lib/break_escape/engine.rb

Replace entire contents with:

require 'pundit'

module BreakEscape
  class Engine < ::Rails::Engine
    isolate_namespace BreakEscape

    config.generators do |g|
      g.test_framework :test_unit, fixture: true
      g.assets false
      g.helper false
    end

    # Load lib directory
    config.autoload_paths << File.expand_path('../', __dir__)

    # Pundit authorization
    config.after_initialize do
      if defined?(Pundit)
        BreakEscape::ApplicationController.include Pundit::Authorization
      end
    end

    # Static files from public/break_escape
    config.middleware.use ::ActionDispatch::Static, "#{root}/public"
  end
end

Save and close (:wq in vim)

1.3 Update Version

vim lib/break_escape/version.rb

Replace with:

module BreakEscape
  VERSION = '1.0.0'
end

Save and close

1.4 Update Gemfile

vim Gemfile

Replace entire contents with:

source 'https://rubygems.org'

gemspec

# Development dependencies
group :development, :test do
  gem 'sqlite3'
  gem 'pry'
  gem 'pry-byebug'
end

Save and close

1.5 Update Gemspec

vim break_escape.gemspec

Replace entire contents with:

require_relative "lib/break_escape/version"

Gem::Specification.new do |spec|
  spec.name        = "break_escape"
  spec.version     = BreakEscape::VERSION
  spec.authors     = ["BreakEscape Team"]
  spec.email       = ["team@example.com"]
  spec.summary     = "BreakEscape escape room game engine"
  spec.description = "Rails engine for BreakEscape cybersecurity training escape room game"
  spec.license     = "MIT"

  spec.files = Dir.chdir(File.expand_path(__dir__)) do
    Dir["{app,config,db,lib,public}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
  end

  spec.add_dependency "rails", ">= 7.0"
  spec.add_dependency "pundit", "~> 2.3"
end

Save and close

1.6 Install Dependencies

bundle install

Expected output: Dependencies installed successfully

1.7 Test Engine Loads

# Start Rails console
rails console

# Verify engine loads
puts BreakEscape::Engine.root
# Should print engine root path

# Exit console
exit

Expected output: Path printed successfully, no errors

1.8 Commit

git add -A
git status  # Review changes
git commit -m "feat: Generate Rails Engine structure

- Create mountable engine with isolated namespace
- Configure Pundit authorization
- Set up gemspec with dependencies
- Configure generators for test_unit with fixtures"

git push -u origin rails-engine-migration

Phase 2: Move Game Files to public/ (Week 1, ~4 hours)

Objectives

  • Move static game files to public/break_escape/
  • Preserve git history using mv
  • Update any absolute paths if needed
  • Verify files accessible

2.1 Create Directory Structure

# Create public directory for game assets
mkdir -p public/break_escape

2.2 Move Game Files

IMPORTANT: Use mv, not cp, to preserve git history

# Move JavaScript files
mv js public/break_escape/

# Move CSS files
mv css public/break_escape/

# Move assets (images, sounds, Tiled maps)
mv assets public/break_escape/

# Keep index.html as reference (don't move, copy for backup)
cp index.html public/break_escape/index.html.reference

2.3 Verify Files Moved

# Check that files exist in new location
ls -la public/break_escape/
# Should see: js/ css/ assets/ index.html.reference

# Check that old locations are gone
ls js 2>/dev/null && echo "ERROR: js still exists!" || echo "✓ js moved"
ls css 2>/dev/null && echo "ERROR: css still exists!" || echo "✓ css moved"
ls assets 2>/dev/null && echo "ERROR: assets still exists!" || echo "✓ assets moved"

Expected output: ✓ for all three checks

2.4 Update .gitignore

# Ensure public/break_escape is NOT ignored
vim .gitignore

Check that these lines are NOT present:

public/break_escape/
public/break_escape/**/*

If they are, remove them

Verify git sees the files:

git status | grep "public/break_escape"
# Should show moved files

2.5 Commit

git add -A
git status  # Review - should show renames/moves
git commit -m "refactor: Move game files to public/break_escape/

- Move js/ to public/break_escape/js/
- Move css/ to public/break_escape/css/
- Move assets/ to public/break_escape/assets/
- Preserve git history with mv command
- Keep index.html.reference for reference"

git push

Phase 3: Create Scenario ERB Templates (Week 1-2, ~6 hours)

Objectives

  • Create app/assets/scenarios directory structure
  • Convert scenario JSON files to ERB templates
  • Add randomization for passwords/pins
  • Keep .ink files in scenarios/ink/ (will be served directly)

3.1 Create Directory Structure

# Create scenarios directory
mkdir -p app/assets/scenarios

# List current scenarios
ls scenarios/*.json

Note the scenario names (e.g., ceo_exfil, cybok_heist, biometric_breach)

3.2 Process Each Scenario

For EACH scenario file, follow these steps:

Example: ceo_exfil

# Set scenario name
SCENARIO="ceo_exfil"

# Create scenario directory
mkdir -p "app/assets/scenarios/${SCENARIO}"

# Move scenario JSON and rename to .erb
mv "scenarios/${SCENARIO}.json" "app/assets/scenarios/${SCENARIO}/scenario.json.erb"

# Verify
ls -la "app/assets/scenarios/${SCENARIO}/"
# Should see: scenario.json.erb

Edit ERB Template to Add Randomization

vim "app/assets/scenarios/${SCENARIO}/scenario.json.erb"

Find any hardcoded passwords/pins and replace:

Before:

{
  "locked": true,
  "lockType": "password",
  "requires": "admin123"
}

After:

{
  "locked": true,
  "lockType": "password",
  "requires": "<%= random_password %>"
}

For PINs:

"requires": "<%= random_pin %>"

For codes:

"requires": "<%= random_code %>"

Save and close

Repeat for All Scenarios

Complete conversion script for all main scenarios:

#!/bin/bash
# Convert all scenario JSON files to ERB structure

echo "Converting scenario files to ERB templates..."

# Main game scenarios (these are the production scenarios)
MAIN_SCENARIOS=(
  "ceo_exfil"
  "cybok_heist"
  "biometric_breach"
)

# Test/demo scenarios (keep for testing)
TEST_SCENARIOS=(
  "scenario1"
  "scenario2"
  "scenario3"
  "scenario4"
  "npc-hub-demo-ghost-protocol"
  "npc-patrol-lockpick"
  "npc-sprite-test2"
  "test-multiroom-npc"
  "test-npc-face-player"
  "test-npc-patrol"
  "test-npc-personal-space"
  "test-npc-waypoints"
  "test-rfid-multiprotocol"
  "test-rfid"
  "test_complex_multidirection"
  "test_horizontal_layout"
  "test_mixed_room_sizes"
  "test_multiple_connections"
  "test_vertical_layout"
  "timed_messages_example"
  "title-screen-demo"
)

# Process main scenarios
echo ""
echo "=== Processing Main Scenarios ==="
for scenario in "${MAIN_SCENARIOS[@]}"; do
  if [ -f "scenarios/${scenario}.json" ]; then
    echo "Processing: $scenario"

    # Create directory
    mkdir -p "app/assets/scenarios/${scenario}"

    # Move and rename (just rename to .erb, don't modify content yet)
    mv "scenarios/${scenario}.json" "app/assets/scenarios/${scenario}/scenario.json.erb"

    echo "  ✓ Moved to app/assets/scenarios/${scenario}/scenario.json.erb"
    echo "  → Edit later to add <%= random_password %>, <%= random_pin %>, etc."
  else
    echo "  ⚠ File not found: scenarios/${scenario}.json (skipping)"
  fi
done

# Process test scenarios
echo ""
echo "=== Processing Test Scenarios ==="
for scenario in "${TEST_SCENARIOS[@]}"; do
  if [ -f "scenarios/${scenario}.json" ]; then
    echo "Processing: $scenario"

    # Create directory
    mkdir -p "app/assets/scenarios/${scenario}"

    # Move and rename
    mv "scenarios/${scenario}.json" "app/assets/scenarios/${scenario}/scenario.json.erb"

    echo "  ✓ Moved to app/assets/scenarios/${scenario}/scenario.json.erb"
  else
    echo "  ⚠ File not found: scenarios/${scenario}.json (skipping)"
  fi
done

echo ""
echo "=== Summary ==="
echo "Converted files:"
find app/assets/scenarios -name "scenario.json.erb" | wc -l
echo ""
echo "Directory structure:"
ls -d app/assets/scenarios/*/
echo ""
echo "✓ Conversion complete!"
echo ""
echo "IMPORTANT:"
echo "- Files have been renamed to .erb but content is still JSON"
echo "- ERB randomization (random_password, etc.) will be added in Phase 4"
echo "- For now, scenarios work as-is with static passwords"

Save this script as scripts/convert-scenarios.sh and run:

chmod +x scripts/convert-scenarios.sh
./scripts/convert-scenarios.sh

Alternative: Manual conversion for main scenarios only:

# If you only want to convert the 3 main scenarios manually:

# CEO Exfiltration
mkdir -p app/assets/scenarios/ceo_exfil
mv scenarios/ceo_exfil.json app/assets/scenarios/ceo_exfil/scenario.json.erb

# CybOK Heist
mkdir -p app/assets/scenarios/cybok_heist
mv scenarios/cybok_heist.json app/assets/scenarios/cybok_heist/scenario.json.erb

# Biometric Breach
mkdir -p app/assets/scenarios/biometric_breach
mv scenarios/biometric_breach.json app/assets/scenarios/biometric_breach/scenario.json.erb

# Verify
ls -la app/assets/scenarios/*/scenario.json.erb

Note:

  • Files are renamed to .erb extension but content remains valid JSON
  • ERB randomization code (<%= random_password %>) will be added later in Phase 4
  • This preserves git history and allows immediate testing
  • Test scenarios are useful for development but don't need randomization

3.3 Handle Ink Files

Keep .ink files in scenarios/ink/ - they will be served directly

# Verify ink files are still in place
ls scenarios/ink/*.ink | wc -l
# Should show ~30 files

echo "✓ Ink files staying in scenarios/ink/ (served via JIT compilation)"

3.4 Remove Old scenarios Directory (Optional)

Only after verifying all scenario.json.erb files are created:

# Check if any .json files remain
ls scenarios/*.json 2>/dev/null

# If empty, safe to remove (or keep as backup)
# mv scenarios/old_scenarios_backup

3.5 Test ERB Processing

# Start Rails console
rails console

# Test ERB processing
template_path = Rails.root.join('app/assets/scenarios/ceo_exfil/scenario.json.erb')
erb = ERB.new(File.read(template_path))

# Create binding with random values
class TestBinding
  def initialize
    @random_password = 'TEST123'
    @random_pin = '1234'
    @random_code = 'abcd'
  end
  attr_reader :random_password, :random_pin, :random_code
  def get_binding; binding; end
end

output = erb.result(TestBinding.new.get_binding)
json = JSON.parse(output)
puts "✓ ERB processing works!"

exit

Expected output: "✓ ERB processing works!" with no JSON parse errors

3.6 Commit

git add -A
git status  # Review changes
git commit -m "refactor: Convert scenarios to ERB templates

- Move scenario JSON files to app/assets/scenarios/
- Rename to .erb extension
- Add randomization for passwords and PINs
- Keep .ink files in scenarios/ink/ for JIT compilation
- Each scenario now in own directory"

git push

Phase 4: Database Setup (Week 2-3, ~6 hours)

Objectives

  • Generate database migrations
  • Create Mission and Game models
  • Set up polymorphic associations
  • Run migrations

4.1 Generate Migrations

# Generate missions migration
rails generate migration CreateBreakEscapeMissions

# Generate games migration
rails generate migration CreateBreakEscapeGames

# List generated migrations
ls db/migrate/

4.2 Edit Missions Migration

# Find the missions migration file
MIGRATION=$(ls db/migrate/*_create_break_escape_missions.rb)
vim "$MIGRATION"

Replace entire contents with:

class CreateBreakEscapeMissions < ActiveRecord::Migration[7.0]
  def change
    create_table :break_escape_missions do |t|
      t.string :name, null: false
      t.string :display_name, null: false
      t.text :description
      t.boolean :published, default: false, null: false
      t.integer :difficulty_level, default: 1, null: false

      t.timestamps
    end

    add_index :break_escape_missions, :name, unique: true
    add_index :break_escape_missions, :published
  end
end

Save and close

4.3 Edit Games Migration

# Find the games migration file
MIGRATION=$(ls db/migrate/*_create_break_escape_games.rb)
vim "$MIGRATION"

Replace entire contents with:

class CreateBreakEscapeGames < ActiveRecord::Migration[7.0]
  def change
    create_table :break_escape_games do |t|
      # Polymorphic player
      t.references :player, polymorphic: true, null: false, index: true

      # Mission reference
      t.references :mission, null: false, foreign_key: { to_table: :break_escape_missions }

      # Scenario snapshot (ERB-generated)
      t.jsonb :scenario_data, null: false

      # Player state
      t.jsonb :player_state, null: false, default: {
        currentRoom: nil,
        unlockedRooms: [],
        unlockedObjects: [],
        inventory: [],
        encounteredNPCs: [],
        globalVariables: {},
        biometricSamples: [],
        biometricUnlocks: [],
        bluetoothDevices: [],
        notes: [],
        health: 100
      }

      # Metadata
      t.string :status, default: 'in_progress', null: false
      t.datetime :started_at
      t.datetime :completed_at
      t.integer :score, default: 0, null: false

      t.timestamps
    end

    add_index :break_escape_games,
              [:player_type, :player_id, :mission_id],
              unique: true,
              name: 'index_games_on_player_and_mission'
    add_index :break_escape_games, :scenario_data, using: :gin
    add_index :break_escape_games, :player_state, using: :gin
    add_index :break_escape_games, :status
  end
end

Save and close

4.4 Run Migrations

# Run migrations
rails db:migrate

# Verify tables created
rails runner "puts ActiveRecord::Base.connection.tables"
# Should include: break_escape_missions, break_escape_games

Expected output: Tables listed successfully

4.5 Generate Model Files

# Generate Mission model (skip migration since we already created it)
rails generate model Mission --skip-migration

# Generate Game model
rails generate model Game --skip-migration

4.6 Edit Mission Model

vim app/models/break_escape/mission.rb

Replace entire contents with:

module BreakEscape
  class Mission < ApplicationRecord
    self.table_name = 'break_escape_missions'

    has_many :games, class_name: 'BreakEscape::Game', dependent: :destroy

    validates :name, presence: true, uniqueness: true
    validates :display_name, presence: true
    validates :difficulty_level, inclusion: { in: 1..5 }

    scope :published, -> { where(published: true) }

    # Path to scenario directory
    def scenario_path
      Rails.root.join('app', 'assets', 'scenarios', name)
    end

    # Generate scenario data via ERB
    def generate_scenario_data
      template_path = scenario_path.join('scenario.json.erb')
      raise "Scenario template not found: #{name}" unless File.exist?(template_path)

      erb = ERB.new(File.read(template_path))
      binding_context = ScenarioBinding.new
      output = erb.result(binding_context.get_binding)

      JSON.parse(output)
    rescue JSON::ParserError => e
      raise "Invalid JSON in #{name} after ERB processing: #{e.message}"
    end

    # Binding context for ERB variables
    class ScenarioBinding
      def initialize
        @random_password = SecureRandom.alphanumeric(8)
        @random_pin = rand(1000..9999).to_s
        @random_code = SecureRandom.hex(4)
      end

      attr_reader :random_password, :random_pin, :random_code

      def get_binding
        binding
      end
    end
  end
end

Save and close

4.7 Edit Game Model

vim app/models/break_escape/game.rb

Replace entire contents with:

module BreakEscape
  class Game < ApplicationRecord
    self.table_name = 'break_escape_games'

    # Associations
    belongs_to :player, polymorphic: true
    belongs_to :mission, class_name: 'BreakEscape::Mission'

    # Validations
    validates :player, presence: true
    validates :mission, presence: true
    validates :status, inclusion: { in: %w[in_progress completed abandoned] }

    # Scopes
    scope :active, -> { where(status: 'in_progress') }
    scope :completed, -> { where(status: 'completed') }

    # Callbacks
    before_create :generate_scenario_data
    before_create :initialize_player_state
    before_create :set_started_at

    # Room management
    def unlock_room!(room_id)
      player_state['unlockedRooms'] ||= []
      player_state['unlockedRooms'] << room_id unless player_state['unlockedRooms'].include?(room_id)
      save!
    end

    def room_unlocked?(room_id)
      player_state['unlockedRooms']&.include?(room_id) || start_room?(room_id)
    end

    def start_room?(room_id)
      scenario_data['startRoom'] == room_id
    end

    # Object management
    def unlock_object!(object_id)
      player_state['unlockedObjects'] ||= []
      player_state['unlockedObjects'] << object_id unless player_state['unlockedObjects'].include?(object_id)
      save!
    end

    def object_unlocked?(object_id)
      player_state['unlockedObjects']&.include?(object_id)
    end

    # Inventory management
    def add_inventory_item!(item)
      player_state['inventory'] ||= []
      player_state['inventory'] << item
      save!
    end

    def remove_inventory_item!(item_id)
      player_state['inventory']&.reject! { |item| item['id'] == item_id }
      save!
    end

    # NPC tracking
    def encounter_npc!(npc_id)
      player_state['encounteredNPCs'] ||= []
      player_state['encounteredNPCs'] << npc_id unless player_state['encounteredNPCs'].include?(npc_id)
      save!
    end

    # Global variables (synced with client)
    def update_global_variables!(variables)
      player_state['globalVariables'] ||= {}
      player_state['globalVariables'].merge!(variables)
      save!
    end

    # Minigame state
    def add_biometric_sample!(sample)
      player_state['biometricSamples'] ||= []
      player_state['biometricSamples'] << sample
      save!
    end

    def add_bluetooth_device!(device)
      player_state['bluetoothDevices'] ||= []
      unless player_state['bluetoothDevices'].any? { |d| d['mac'] == device['mac'] }
        player_state['bluetoothDevices'] << device
      end
      save!
    end

    def add_note!(note)
      player_state['notes'] ||= []
      player_state['notes'] << note
      save!
    end

    # Health management
    def update_health!(value)
      player_state['health'] = value.clamp(0, 100)
      save!
    end

    # Scenario data access
    def room_data(room_id)
      scenario_data.dig('rooms', room_id)
    end

    def filtered_room_data(room_id)
      room = room_data(room_id)&.deep_dup
      return nil unless room

      # Remove solutions
      room.delete('requires')
      room.delete('lockType') if room['locked']

      # Remove solutions from objects
      room['objects']&.each do |obj|
        obj.delete('requires')
        obj.delete('lockType') if obj['locked']
        obj.delete('contents') if obj['locked']
      end

      room
    end

    # Unlock validation
    def validate_unlock(target_type, target_id, attempt, method)
      if target_type == 'door'
        room = room_data(target_id)
        return false unless room && room['locked']

        case method
        when 'key'
          room['requires'] == attempt
        when 'pin', 'password'
          room['requires'].to_s == attempt.to_s
        when 'lockpick'
          true # Client minigame succeeded
        else
          false
        end
      else
        # Find object in all rooms
        scenario_data['rooms'].each do |_room_id, room_data|
          object = room_data['objects']&.find { |obj| obj['id'] == target_id }
          next unless object && object['locked']

          case method
          when 'key'
            return object['requires'] == attempt
          when 'pin', 'password'
            return object['requires'].to_s == attempt.to_s
          when 'lockpick'
            return true
          end
        end
        false
      end
    end

    private

    def generate_scenario_data
      self.scenario_data = mission.generate_scenario_data
    end

    def initialize_player_state
      self.player_state ||= {}
      self.player_state['currentRoom'] ||= scenario_data['startRoom']
      self.player_state['unlockedRooms'] ||= [scenario_data['startRoom']]
      self.player_state['unlockedObjects'] ||= []
      self.player_state['inventory'] ||= []
      self.player_state['encounteredNPCs'] ||= []
      self.player_state['globalVariables'] ||= {}
      self.player_state['biometricSamples'] ||= []
      self.player_state['biometricUnlocks'] ||= []
      self.player_state['bluetoothDevices'] ||= []
      self.player_state['notes'] ||= []
      self.player_state['health'] ||= 100
    end

    def set_started_at
      self.started_at ||= Time.current
    end
  end
end

Save and close

4.8 Test Models

# Start Rails console
rails console

# Test Mission model
mission = BreakEscape::Mission.new(name: 'test', display_name: 'Test')
puts mission.valid?  # Should be true

# Test scenario path
mission.name = 'ceo_exfil'
puts mission.scenario_path
# Should print: /home/user/BreakEscape/app/assets/scenarios/ceo_exfil

exit

Expected output: Valid model, correct path

4.9 Commit

git add -A
git status  # Review changes
git commit -m "feat: Add database schema and models

- Create break_escape_missions table (metadata only)
- Create break_escape_games table (state + scenario snapshot)
- Add Mission model with ERB scenario generation
- Add Game model with state management methods
- Use JSONB for flexible state storage
- Polymorphic player association (User/DemoUser)"

git push

Phase 5: Seed Data (Week 3, ~2 hours)

Objectives

  • Create simple seed file for mission metadata
  • No scenario data in database (generated on-demand)
  • Test mission creation

5.1 Create Seed File

vim db/seeds.rb

Replace entire contents with:

puts "Creating BreakEscape missions..."

# List all scenario directories
scenario_dirs = Dir.glob(Rails.root.join('app/assets/scenarios/*')).select { |f| File.directory?(f) }

scenario_dirs.each do |dir|
  scenario_name = File.basename(dir)
  next if scenario_name == 'common'  # Skip common directory if it exists

  # Create mission metadata
  mission = BreakEscape::Mission.find_or_initialize_by(name: scenario_name)

  if mission.new_record?
    mission.display_name = scenario_name.titleize
    mission.description = "Play the #{scenario_name.titleize} scenario"
    mission.published = true
    mission.difficulty_level = 3  # Default, can be updated later
    mission.save!
    puts "  ✓ Created: #{mission.display_name}"
  else
    puts "  - Exists: #{mission.display_name}"
  end
end

puts "Done! Created #{BreakEscape::Mission.count} missions."

Save and close

5.2 Run Seeds

# Run seeds
rails db:seed

# Verify missions created
rails runner "puts BreakEscape::Mission.pluck(:name, :display_name)"

Expected output: List of missions created

5.3 Test ERB Generation

# Start Rails console
rails console

# Test full flow
mission = BreakEscape::Mission.first
puts "Testing: #{mission.display_name}"

scenario_data = mission.generate_scenario_data
puts "✓ Scenario data generated (#{scenario_data.keys.length} keys)"
puts "  Start room: #{scenario_data['startRoom']}"

# Check for randomization
if scenario_data.to_s.include?('random_password')
  puts "✗ ERROR: ERB variable not replaced!"
else
  puts "✓ ERB variables replaced"
end

exit

Expected output: Scenario generated successfully, no ERB variables in output

5.4 Commit

git add -A
git commit -m "feat: Add seed file for mission metadata

- Create missions from scenario directories
- Auto-discover scenarios in app/assets/scenarios/
- Simple metadata only (no scenario data in DB)
- Scenario data generated on-demand via ERB"

git push

Phase 6: Controllers and Routes (Week 4-5, ~12 hours)

This phase is long - broken into sub-phases

6.1 Generate Controllers

# Generate main controllers
rails generate controller break_escape/missions index show
rails generate controller break_escape/games show

# Generate API controller
mkdir -p app/controllers/break_escape/api
rails generate controller break_escape/api/games --skip-routes

6.2 Configure Routes

vim config/routes.rb

Replace entire contents with:

BreakEscape::Engine.routes.draw do
  # Mission selection
  resources :missions, only: [:index, :show]

  # Game management
  resources :games, only: [:show, :create] do
    member do
      # Scenario and NPC data
      get 'scenario'  # Returns scenario_data JSON
      get 'ink'       # Returns NPC script (JIT compiled)

      # API endpoints
      scope module: :api do
        get 'bootstrap'     # Initial game data
        put 'sync_state'    # Periodic state sync
        post 'unlock'       # Validate unlock attempt
        post 'inventory'    # Update inventory
      end
    end
  end

  root to: 'missions#index'
end

Save and close

6.3 Test Routes

# List routes
rails routes | grep break_escape

# Should see:
# - missions_path
# - mission_path
# - games_path
# - game_path
# - scenario_game_path
# - ink_game_path
# - bootstrap_game_path
# - etc.

Expected output: Routes listed successfully

6.4 Edit ApplicationController

vim app/controllers/break_escape/application_controller.rb

Replace entire contents with:

module BreakEscape
  class ApplicationController < ActionController::Base
    protect_from_forgery with: :exception

    # Include Pundit if available
    include Pundit::Authorization if defined?(Pundit)

    # Helper method to get current player (polymorphic)
    def current_player
      if BreakEscape.standalone_mode?
        # Standalone mode - get/create demo user
        @current_player ||= DemoUser.first_or_create!(handle: 'demo_player')
      else
        # Mounted mode - use Hacktivity's current_user
        current_user
      end
    end
    helper_method :current_player

    # Handle authorization errors
    rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

    private

    def user_not_authorized
      flash[:alert] = "You are not authorized to perform this action."
      redirect_to(request.referrer || root_path)
    end
  end
end

Save and close

6.5 Edit MissionsController

vim app/controllers/break_escape/missions_controller.rb

Replace entire contents with:

module BreakEscape
  class MissionsController < ApplicationController
    def index
      @missions = if defined?(Pundit)
                    policy_scope(Mission)
                  else
                    Mission.published
                  end
    end

    def show
      @mission = Mission.find(params[:id])
      authorize @mission if defined?(Pundit)

      # Create or find game instance for current player
      @game = Game.find_or_create_by!(
        player: current_player,
        mission: @mission
      )

      redirect_to game_path(@game)
    end
  end
end

Save and close

6.6 Edit GamesController

vim app/controllers/break_escape/games_controller.rb

Replace entire contents with:

require 'open3'

module BreakEscape
  class GamesController < ApplicationController
    before_action :set_game, only: [:show, :scenario, :ink]

    def show
      authorize @game if defined?(Pundit)
      @mission = @game.mission
    end

    # GET /games/:id/scenario
    # Returns scenario JSON for this game instance
    def scenario
      authorize @game if defined?(Pundit)
      render json: @game.scenario_data
    end

    # GET /games/:id/ink?npc=helper1
    # Returns NPC script (JIT compiled if needed)
    def ink
      authorize @game if defined?(Pundit)

      npc_id = params[:npc]
      return render_error('Missing npc parameter', :bad_request) unless npc_id.present?

      # Find NPC in scenario data
      npc = find_npc_in_scenario(npc_id)
      return render_error('NPC not found in scenario', :not_found) unless npc

      # Resolve ink file path and compile if needed
      ink_json_path = resolve_and_compile_ink(npc['storyPath'])
      return render_error('Ink script not found', :not_found) unless ink_json_path && File.exist?(ink_json_path)

      # Serve compiled JSON
      render json: JSON.parse(File.read(ink_json_path))
    rescue JSON::ParserError => e
      render_error("Invalid JSON in compiled ink: #{e.message}", :internal_server_error)
    end

    private

    def set_game
      @game = Game.find(params[:id])
    end

    def find_npc_in_scenario(npc_id)
      @game.scenario_data['rooms']&.each do |_room_id, room_data|
        npc = room_data['npcs']&.find { |n| n['id'] == npc_id }
        return npc if npc
      end
      nil
    end

    # Resolve ink path and compile if necessary
    def resolve_and_compile_ink(story_path)
      base_path = Rails.root.join(story_path)
      json_path = find_compiled_json(base_path)
      ink_path = find_ink_source(base_path)

      if ink_path && needs_compilation?(ink_path, json_path)
        Rails.logger.info "[BreakEscape] Compiling #{File.basename(ink_path)}..."
        json_path = compile_ink(ink_path)
      end

      json_path
    end

    def find_compiled_json(base_path)
      return base_path if File.exist?(base_path)

      ink_json_path = base_path.to_s.gsub(/\.json$/, '.ink.json')
      return Pathname.new(ink_json_path) if File.exist?(ink_json_path)

      json_path = base_path.to_s.gsub(/\.ink\.json$/, '.json')
      return Pathname.new(json_path) if File.exist?(json_path)

      nil
    end

    def find_ink_source(base_path)
      ink_path = base_path.to_s.gsub(/\.(ink\.)?json$/, '.ink')
      File.exist?(ink_path) ? Pathname.new(ink_path) : nil
    end

    def needs_compilation?(ink_path, json_path)
      return true unless json_path && File.exist?(json_path)
      File.mtime(ink_path) > File.mtime(json_path)
    end

    def compile_ink(ink_path)
      output_path = ink_path.to_s.gsub(/\.ink$/, '.json')
      inklecate_path = Rails.root.join('bin', 'inklecate')

      stdout, stderr, status = Open3.capture3(
        inklecate_path.to_s,
        '-o', output_path,
        ink_path.to_s
      )

      unless status.success?
        Rails.logger.error "[BreakEscape] Ink compilation failed: #{stderr}"
        raise "Ink compilation failed for #{File.basename(ink_path)}: #{stderr}"
      end

      if stderr.present?
        Rails.logger.warn "[BreakEscape] Ink compilation warnings: #{stderr}"
      end

      Rails.logger.info "[BreakEscape] Compiled #{File.basename(ink_path)} (#{(File.size(output_path) / 1024.0).round(2)} KB)"

      Pathname.new(output_path)
    end

    def render_error(message, status)
      render json: { error: message }, status: status
    end
  end
end

Save and close

6.7 Edit API GamesController

vim app/controllers/break_escape/api/games_controller.rb

Replace entire contents with:

module BreakEscape
  module Api
    class GamesController < ApplicationController
      before_action :set_game

      # GET /games/:id/bootstrap
      # Initial game data for client
      def bootstrap
        authorize @game if defined?(Pundit)

        render json: {
          gameId: @game.id,
          missionName: @game.mission.display_name,
          startRoom: @game.scenario_data['startRoom'],
          playerState: @game.player_state,
          roomLayout: build_room_layout
        }
      end

      # PUT /games/:id/sync_state
      # Periodic state sync from client
      def sync_state
        authorize @game if defined?(Pundit)

        # Update allowed fields
        if params[:currentRoom]
          @game.player_state['currentRoom'] = params[:currentRoom]
        end

        if params[:globalVariables]
          @game.update_global_variables!(params[:globalVariables].to_unsafe_h)
        end

        @game.save!

        render json: { success: true }
      end

      # POST /games/:id/unlock
      # Validate unlock attempt
      def unlock
        authorize @game if defined?(Pundit)

        target_type = params[:targetType]
        target_id = params[:targetId]
        attempt = params[:attempt]
        method = params[:method]

        is_valid = @game.validate_unlock(target_type, target_id, attempt, method)

        if is_valid
          if target_type == 'door'
            @game.unlock_room!(target_id)
            room_data = @game.filtered_room_data(target_id)

            render json: {
              success: true,
              type: 'door',
              roomData: room_data
            }
          else
            @game.unlock_object!(target_id)
            render json: {
              success: true,
              type: 'object'
            }
          end
        else
          render json: {
            success: false,
            message: 'Invalid attempt'
          }, status: :unprocessable_entity
        end
      end

      # POST /games/:id/inventory
      # Update inventory
      def inventory
        authorize @game if defined?(Pundit)

        action = params[:action]
        item = params[:item]

        case action
        when 'add'
          @game.add_inventory_item!(item.to_unsafe_h)
          render json: { success: true, inventory: @game.player_state['inventory'] }
        when 'remove'
          @game.remove_inventory_item!(item['id'])
          render json: { success: true, inventory: @game.player_state['inventory'] }
        else
          render json: { success: false, message: 'Invalid action' }, status: :bad_request
        end
      end

      private

      def set_game
        @game = Game.find(params[:id])
      end

      def build_room_layout
        layout = {}
        @game.scenario_data['rooms'].each do |room_id, room_data|
          layout[room_id] = {
            connections: room_data['connections'],
            locked: room_data['locked'] || false
          }
        end
        layout
      end
    end
  end
end

Save and close

6.8 Test Controllers

# Start Rails server
rails server

# In another terminal, test endpoints
# (Assuming you have a game with id=1)

# Test scenario endpoint
curl http://localhost:3000/break_escape/games/1/scenario

# Test bootstrap endpoint
curl http://localhost:3000/break_escape/games/1/bootstrap

Expected output: JSON responses (may get auth errors if Pundit enabled, that's fine for now)

6.9 Commit

git add -A
git commit -m "feat: Add controllers and routes

- Add MissionsController for scenario selection
- Add GamesController with scenario/ink endpoints
- Add JIT Ink compilation logic
- Add API::GamesController for game state management
- Configure routes with REST + API endpoints
- Add authorization hooks (Pundit)"

git push

Continue to Phase 7 in next section...


Progress Tracking

Use this checklist to track your progress:

  • Phase 1: Setup Rails Engine (8 hours)
  • Phase 2: Move Game Files (4 hours)
  • Phase 3: Create Scenario Templates (6 hours)
  • Phase 4: Database Setup (6 hours)
  • Phase 5: Seed Data (2 hours)
  • Phase 6: Controllers and Routes (12 hours)
  • Phase 7: Policies (4 hours)
  • Phase 8: Views (6 hours)
  • Phase 9: Client Integration (12 hours)
  • Phase 10: Testing (8 hours)
  • Phase 11: Standalone Mode (4 hours)
  • Phase 12: Deployment (6 hours)

Total: ~78 hours over 10-12 weeks


Continued in Part 2

This document contains Phases 1-6. Continue with the next document for:

  • Phase 7: Policies
  • Phase 8: Views
  • Phase 9: Client Integration
  • Phase 10: Testing
  • Phase 11: Standalone Mode
  • Phase 12: Deployment

See 03_IMPLEMENTATION_PLAN_PART2.md for continuation.