diff --git a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..c207694 --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1506 @@ +# 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: + +```bash +# 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 + +```bash +# 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: + +```bash +# Open engine file +vim lib/break_escape/engine.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim lib/break_escape/version.rb +``` + +**Replace with:** + +```ruby +module BreakEscape + VERSION = '1.0.0' +end +``` + +**Save and close** + +### 1.4 Update Gemfile + +```bash +vim Gemfile +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim break_escape.gemspec +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +bundle install +``` + +**Expected output:** Dependencies installed successfully + +### 1.7 Test Engine Loads + +```bash +# 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 + +```bash +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 + +```bash +# 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 + +```bash +# 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 + +```bash +# 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 + +```bash +# 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:** + +```bash +git status | grep "public/break_escape" +# Should show moved files +``` + +### 2.5 Commit + +```bash +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 + +```bash +# 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 + +```bash +# 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 + +```bash +vim "app/assets/scenarios/${SCENARIO}/scenario.json.erb" +``` + +**Find any hardcoded passwords/pins and replace:** + +**Before:** +```json +{ + "locked": true, + "lockType": "password", + "requires": "admin123" +} +``` + +**After:** +```erb +{ + "locked": true, + "lockType": "password", + "requires": "<%= random_password %>" +} +``` + +**For PINs:** +```erb +"requires": "<%= random_pin %>" +``` + +**For codes:** +```erb +"requires": "<%= random_code %>" +``` + +**Save and close** + +#### Repeat for All Scenarios + +```bash +# List all scenario JSON files +for file in scenarios/*.json; do + base=$(basename "$file" .json) + echo "Processing: $base" + + # Create directory + mkdir -p "app/assets/scenarios/${base}" + + # Move file + mv "$file" "app/assets/scenarios/${base}/scenario.json.erb" + + echo " ✓ Moved to app/assets/scenarios/${base}/scenario.json.erb" + echo " → Remember to edit for randomization" +done +``` + +**Note:** You'll need to manually edit each .erb file to add randomization + +### 3.3 Handle Ink Files + +**Keep .ink files in scenarios/ink/ - they will be served directly** + +```bash +# 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:** + +```bash +# 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 + +```bash +# 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 + +```bash +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 + +```bash +# 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 + +```bash +# Find the missions migration file +MIGRATION=$(ls db/migrate/*_create_break_escape_missions.rb) +vim "$MIGRATION" +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# Find the games migration file +MIGRATION=$(ls db/migrate/*_create_break_escape_games.rb) +vim "$MIGRATION" +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# 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 + +```bash +# 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 + +```bash +vim app/models/break_escape/mission.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim app/models/break_escape/game.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# 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 + +```bash +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 + +```bash +vim db/seeds.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# 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 + +```bash +# 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 + +```bash +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 + +```bash +# 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 + +```bash +vim config/routes.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# 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 + +```bash +vim app/controllers/break_escape/application_controller.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim app/controllers/break_escape/missions_controller.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim app/controllers/break_escape/games_controller.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +vim app/controllers/break_escape/api/games_controller.rb +``` + +**Replace entire contents with:** + +```ruby +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 + +```bash +# 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 + +```bash +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. diff --git a/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md new file mode 100644 index 0000000..b7deb6f --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/03_IMPLEMENTATION_PLAN_PART2.md @@ -0,0 +1,1464 @@ +# BreakEscape Rails Engine - Implementation Plan (Part 2) + +**Continued from 03_IMPLEMENTATION_PLAN.md (Phases 7-12)** + +--- + +## Phase 7: Authorization Policies (Week 5, ~4 hours) + +### Objectives + +- Create Pundit policies for Game and Mission +- Implement authorization rules +- Test policy logic + +### 7.1 Create Policy Directory + +```bash +mkdir -p app/policies/break_escape +``` + +### 7.2 Create ApplicationPolicy + +```bash +vim app/policies/break_escape/application_policy.rb +``` + +**Add:** + +```ruby +module BreakEscape + class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NotImplementedError + end + + private + + attr_reader :user, :scope + end + end +end +``` + +**Save and close** + +### 7.3 Create GamePolicy + +```bash +vim app/policies/break_escape/game_policy.rb +``` + +**Add:** + +```ruby +module BreakEscape + class GamePolicy < ApplicationPolicy + def show? + # Owner or admin/account_manager + record.player == user || user&.admin? || user&.account_manager? + end + + def update? + show? + end + + def scenario? + show? + end + + def ink? + show? + end + + def bootstrap? + show? + end + + def sync_state? + show? + end + + def unlock? + show? + end + + def inventory? + show? + end + + class Scope < Scope + def resolve + if user&.admin? || user&.account_manager? + scope.all + else + scope.where(player: user) + end + end + end + end +end +``` + +**Save and close** + +### 7.4 Create MissionPolicy + +```bash +vim app/policies/break_escape/mission_policy.rb +``` + +**Add:** + +```ruby +module BreakEscape + class MissionPolicy < ApplicationPolicy + def index? + true # Everyone can see mission list + end + + def show? + # Published missions or admin + record.published? || user&.admin? || user&.account_manager? + end + + class Scope < Scope + def resolve + if user&.admin? || user&.account_manager? + scope.all + else + scope.published + end + end + end + end +end +``` + +**Save and close** + +### 7.5 Test Policies + +```bash +# Start Rails console +rails console + +# Test GamePolicy +user = BreakEscape::DemoUser.first_or_create!(handle: 'test_user') +mission = BreakEscape::Mission.first +game = BreakEscape::Game.create!(player: user, mission: mission) + +policy = BreakEscape::GamePolicy.new(user, game) +puts policy.show? # Should be true (owner) + +other_user = BreakEscape::DemoUser.create!(handle: 'other_user') +other_policy = BreakEscape::GamePolicy.new(other_user, game) +puts other_policy.show? # Should be false (not owner) + +# Test MissionPolicy +mission_policy = BreakEscape::MissionPolicy.new(user, mission) +puts mission_policy.show? # Should be true if published + +exit +``` + +**Expected output:** Policy logic works correctly + +### 7.6 Commit + +```bash +git add -A +git commit -m "feat: Add Pundit authorization policies + +- Add ApplicationPolicy base class +- Add GamePolicy (owner or admin can access) +- Add MissionPolicy (published visible to all) +- Implement Scope for filtering records +- Support admin and account_manager roles" + +git push +``` + +--- + +## Phase 8: Views (Week 5-6, ~6 hours) + +### Objectives + +- Create mission index view (scenario selection) +- Create game show view (game container) +- Add layout with proper asset loading + +### 8.1 Create Missions Index View + +```bash +mkdir -p app/views/break_escape/missions +vim app/views/break_escape/missions/index.html.erb +``` + +**Add:** + +```erb + + + + BreakEscape - Select Mission + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + +

🔓 BreakEscape - Select Your Mission

+ +
+ <% @missions.each do |mission| %> + <%= link_to mission_path(mission), class: 'mission-card' do %> +
<%= mission.display_name %>
+
+ <%= mission.description || "An exciting escape room challenge awaits..." %> +
+
+ Difficulty: <%= "⭐" * mission.difficulty_level %> +
+ <% end %> + <% end %> +
+ + +``` + +**Save and close** + +### 8.2 Create Game Show View + +```bash +mkdir -p app/views/break_escape/games +vim app/views/break_escape/games/show.html.erb +``` + +**Add:** + +```erb + + + + <%= @mission.display_name %> - BreakEscape + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%# Load game CSS %> + <%= stylesheet_link_tag '/break_escape/css/styles.css' %> + + + <%# Game container - Phaser will render here %> +
+ + <%# Loading indicator %> +
+ Loading game... +
+ + <%# Bootstrap configuration for client %> + + + <%# Load game JavaScript (ES6 module) %> + <%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: content_security_policy_nonce %> + + +``` + +**Save and close** + +### 8.3 Test Views + +```bash +# Start Rails server +rails server + +# Visit in browser: +# http://localhost:3000/break_escape/ + +# Should see mission selection screen +# Click a mission +# Should see game view (may not load game yet, that's Phase 9) +``` + +**Expected output:** Views render correctly + +### 8.4 Commit + +```bash +git add -A +git commit -m "feat: Add views for missions and game + +- Add missions index view with grid layout +- Add game show view with Phaser container +- Include CSP nonces for inline scripts +- Bootstrap game configuration in window object +- Load game CSS and JavaScript" + +git push +``` + +--- + +## Phase 9: Client Integration (Week 7-8, ~12 hours) + +### Objectives + +- Create API client wrapper +- Update scenario loading to use API +- Update NPC script loading to use API +- Update unlock validation to use API +- Minimal changes to existing game code + +### 9.1 Create Config File + +```bash +vim public/break_escape/js/config.js +``` + +**Add:** + +```javascript +// API configuration from server +export const GAME_ID = window.breakEscapeConfig?.gameId; +export const API_BASE = window.breakEscapeConfig?.apiBasePath || ''; +export const ASSETS_PATH = window.breakEscapeConfig?.assetsPath || '/break_escape/assets'; +export const CSRF_TOKEN = window.breakEscapeConfig?.csrfToken; + +// Verify config loaded +if (!GAME_ID) { + console.error('BreakEscape: Game ID not configured! Check window.breakEscapeConfig'); +} +``` + +**Save and close** + +### 9.2 Create API Client + +```bash +vim public/break_escape/js/api-client.js +``` + +**Add:** + +```javascript +import { API_BASE, CSRF_TOKEN } from './config.js'; + +/** + * API Client for BreakEscape server communication + */ +export class ApiClient { + /** + * GET request + */ + static async get(endpoint) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + /** + * POST request + */ + static async post(endpoint, data = {}) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || `API Error: ${response.status}`); + } + + return response.json(); + } + + /** + * PUT request + */ + static async put(endpoint, data = {}) { + const response = await fetch(`${API_BASE}${endpoint}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': CSRF_TOKEN + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + // Bootstrap - get initial game data + static async bootstrap() { + return this.get('/bootstrap'); + } + + // Get scenario JSON + static async getScenario() { + return this.get('/scenario'); + } + + // Get NPC script + static async getNPCScript(npcId) { + return this.get(`/ink?npc=${npcId}`); + } + + // Validate unlock attempt + static async unlock(targetType, targetId, attempt, method) { + return this.post('/unlock', { + targetType, + targetId, + attempt, + method + }); + } + + // Update inventory + static async updateInventory(action, item) { + return this.post('/inventory', { + action, + item + }); + } + + // Sync player state + static async syncState(currentRoom, globalVariables) { + return this.put('/sync_state', { + currentRoom, + globalVariables + }); + } +} + +// Export for global access +window.ApiClient = ApiClient; +``` + +**Save and close** + +### 9.3 Update Main Game File + +```bash +vim public/break_escape/js/main.js +``` + +**Find the scenario loading section** (usually near the top of the file or in an init function) + +**Before:** +```javascript +// Load scenario +const scenarioData = await fetch('/scenarios/ceo_exfil.json').then(r => r.json()); +``` + +**After:** +```javascript +// Import API client +import { ApiClient } from './api-client.js'; + +// Load scenario from server +const scenarioData = await ApiClient.getScenario(); +``` + +**Save and close** + +### 9.4 Update NPC Loading + +**Find where NPC scripts are loaded** (likely in `js/systems/npc-manager.js` or similar) + +```bash +# Search for where Ink scripts are loaded +grep -r "storyPath" public/break_escape/js/ +``` + +**Before:** +```javascript +const inkScript = await fetch(npc.storyPath).then(r => r.json()); +``` + +**After:** +```javascript +import { ApiClient } from '../api-client.js'; + +const inkScript = await ApiClient.getNPCScript(npc.id); +``` + +### 9.5 Update Unlock Validation + +**Find where unlocks are validated** (likely in `js/systems/interactions.js` or similar) + +**Before:** +```javascript +// Client-side validation (insecure!) +if (password === requiredPassword) { + unlockRoom(); +} +``` + +**After:** +```javascript +import { ApiClient } from '../api-client.js'; + +// Server-side validation +try { + const result = await ApiClient.unlock('door', roomId, password, 'password'); + if (result.success) { + unlockRoom(result.roomData); + } else { + showError('Invalid password'); + } +} catch (error) { + showError('Unlock failed'); +} +``` + +### 9.6 Add State Sync + +**Add periodic state sync** (in main game update loop or create new file) + +```bash +vim public/break_escape/js/state-sync.js +``` + +**Add:** + +```javascript +import { ApiClient } from './api-client.js'; + +/** + * Periodic state synchronization with server + */ +export class StateSync { + constructor(interval = 30000) { // 30 seconds + this.interval = interval; + this.timer = null; + } + + start() { + this.timer = setInterval(() => this.sync(), this.interval); + console.log('State sync started (every 30s)'); + } + + stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async sync() { + try { + // Get current game state + const currentRoom = window.currentRoom?.name; + const globalVariables = window.gameState?.globalVariables || {}; + + // Sync to server + await ApiClient.syncState(currentRoom, globalVariables); + console.log('✓ State synced to server'); + } catch (error) { + console.error('State sync failed:', error); + } + } +} + +// Create global instance +window.stateSync = new StateSync(); +``` + +**Save and close** + +**Then in main.js, start sync:** + +```javascript +import { StateSync } from './state-sync.js'; + +// After game loads +const stateSync = new StateSync(); +stateSync.start(); +``` + +### 9.7 Update Asset Paths + +**Ensure all asset paths use the correct base** + +```bash +# Find hardcoded asset paths +grep -r "assets/" public/break_escape/js/ | grep -v "ASSETS_PATH" + +# Update any that don't use ASSETS_PATH config +``` + +**Example fix:** + +**Before:** +```javascript +this.load.image('player', 'assets/player.png'); +``` + +**After:** +```javascript +import { ASSETS_PATH } from './config.js'; +this.load.image('player', `${ASSETS_PATH}/player.png`); +``` + +### 9.8 Test Client Integration + +```bash +# Start Rails server +rails server + +# Visit game in browser +# http://localhost:3000/break_escape/ + +# Open browser console +# Verify: +# - No 404 errors for assets +# - Scenario loads from /games/X/scenario +# - NPC scripts load from /games/X/ink?npc=X +# - State sync logs every 30 seconds +``` + +**Expected output:** Game loads and plays, using API for data + +### 9.9 Commit + +```bash +git add -A +git commit -m "feat: Integrate client with Rails API + +- Add api-client.js wrapper for server communication +- Add config.js for API configuration +- Update scenario loading to use API +- Update NPC script loading to use API (JIT compilation) +- Add unlock validation via API +- Add periodic state sync (every 30s) +- Update asset paths to use ASSETS_PATH config +- Minimal changes to existing game logic" + +git push +``` + +--- + +## Phase 10: Testing (Week 9-10, ~8 hours) + +### Objectives + +- Create model tests +- Create controller tests +- Create integration tests +- Ensure all tests pass + +### 10.1 Create Test Fixtures + +```bash +mkdir -p test/fixtures/break_escape +vim test/fixtures/break_escape/missions.yml +``` + +**Add:** + +```yaml +ceo_exfil: + name: ceo_exfil + display_name: CEO Exfiltration + description: Test scenario + published: true + difficulty_level: 3 + +unpublished: + name: test_unpublished + display_name: Unpublished Test + description: Not visible + published: false + difficulty_level: 1 +``` + +**Save and close** + +```bash +vim test/fixtures/break_escape/demo_users.yml +``` + +**Add:** + +```yaml +test_user: + handle: test_user + +other_user: + handle: other_user +``` + +**Save and close** + +```bash +vim test/fixtures/break_escape/games.yml +``` + +**Add:** + +```yaml +active_game: + player: test_user (BreakEscape::DemoUser) + mission: ceo_exfil + scenario_data: { "startRoom": "reception", "rooms": {} } + player_state: { "currentRoom": "reception", "unlockedRooms": ["reception"] } + status: in_progress + score: 0 +``` + +**Save and close** + +### 10.2 Test Mission Model + +```bash +vim test/models/break_escape/mission_test.rb +``` + +**Add:** + +```ruby +require 'test_helper' + +module BreakEscape + class MissionTest < ActiveSupport::TestCase + test "should validate presence of name" do + mission = Mission.new(display_name: 'Test') + assert_not mission.valid? + assert mission.errors[:name].any? + end + + test "should validate uniqueness of name" do + Mission.create!(name: 'test', display_name: 'Test') + duplicate = Mission.new(name: 'test', display_name: 'Test 2') + assert_not duplicate.valid? + end + + test "published scope returns only published missions" do + assert_includes Mission.published, missions(:ceo_exfil) + assert_not_includes Mission.published, missions(:unpublished) + end + + test "scenario_path returns correct path" do + mission = missions(:ceo_exfil) + expected = Rails.root.join('app/assets/scenarios/ceo_exfil') + assert_equal expected, mission.scenario_path + end + end +end +``` + +**Save and close** + +### 10.3 Test Game Model + +```bash +vim test/models/break_escape/game_test.rb +``` + +**Add:** + +```ruby +require 'test_helper' + +module BreakEscape + class GameTest < ActiveSupport::TestCase + setup do + @game = games(:active_game) + end + + test "should belong to player and mission" do + assert @game.player + assert @game.mission + end + + test "should unlock room" do + @game.unlock_room!('office') + assert @game.room_unlocked?('office') + end + + test "should track inventory" do + item = { 'type' => 'key', 'name' => 'Test Key' } + @game.add_inventory_item!(item) + assert_includes @game.player_state['inventory'], item + end + + test "should update health" do + @game.update_health!(50) + assert_equal 50, @game.player_state['health'] + end + + test "should clamp health between 0 and 100" do + @game.update_health!(150) + assert_equal 100, @game.player_state['health'] + + @game.update_health!(-10) + assert_equal 0, @game.player_state['health'] + end + end +end +``` + +**Save and close** + +### 10.4 Test Controllers + +```bash +vim test/controllers/break_escape/missions_controller_test.rb +``` + +**Add:** + +```ruby +require 'test_helper' + +module BreakEscape + class MissionsControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get missions_url + assert_response :success + end + + test "should show published mission" do + mission = missions(:ceo_exfil) + get mission_url(mission) + assert_response :redirect # Redirects to game + end + end +end +``` + +**Save and close** + +### 10.5 Run Tests + +```bash +# Run all tests +rails test + +# Run specific test file +rails test test/models/break_escape/mission_test.rb + +# Run specific test +rails test test/models/break_escape/mission_test.rb:5 +``` + +**Expected output:** All tests pass + +### 10.6 Commit + +```bash +git add -A +git commit -m "test: Add comprehensive test suite + +- Add fixtures for missions, demo_users, games +- Add model tests for Mission and Game +- Add controller tests +- Test validations, scopes, and methods +- All tests passing" + +git push +``` + +--- + +## Phase 11: Standalone Mode (Week 10, ~4 hours) + +### Objectives + +- Create DemoUser model for standalone development +- Add configuration system +- Support both standalone and mounted modes + +### 11.1 Create DemoUser Migration + +```bash +rails generate migration CreateBreakEscapeDemoUsers +``` + +**Edit migration:** + +```bash +MIGRATION=$(ls db/migrate/*_create_break_escape_demo_users.rb) +vim "$MIGRATION" +``` + +**Replace with:** + +```ruby +class CreateBreakEscapeDemoUsers < ActiveRecord::Migration[7.0] + def change + create_table :break_escape_demo_users do |t| + t.string :handle, null: false + t.string :role, default: 'user', null: false + + t.timestamps + end + + add_index :break_escape_demo_users, :handle, unique: true + end +end +``` + +**Save and close** + +```bash +rails db:migrate +``` + +### 11.2 Create DemoUser Model + +```bash +vim app/models/break_escape/demo_user.rb +``` + +**Add:** + +```ruby +module BreakEscape + class DemoUser < ApplicationRecord + self.table_name = 'break_escape_demo_users' + + has_many :games, as: :player, class_name: 'BreakEscape::Game' + + validates :handle, presence: true, uniqueness: true + + # Mimic User role methods + def admin? + role == 'admin' + end + + def account_manager? + role == 'account_manager' + end + end +end +``` + +**Save and close** + +### 11.3 Create Configuration + +```bash +vim lib/break_escape.rb +``` + +**Add:** + +```ruby +require "break_escape/version" +require "break_escape/engine" + +module BreakEscape + class << self + attr_accessor :configuration + end + + def self.configure + self.configuration ||= Configuration.new + yield(configuration) if block_given? + end + + def self.standalone_mode? + configuration&.standalone_mode || false + end + + class Configuration + attr_accessor :standalone_mode, :demo_user_handle + + def initialize + @standalone_mode = false + @demo_user_handle = 'demo_player' + end + end +end + +# Initialize with defaults +BreakEscape.configure {} +``` + +**Save and close** + +### 11.4 Create Initializer + +```bash +mkdir -p config/initializers +vim config/initializers/break_escape.rb +``` + +**Add:** + +```ruby +# BreakEscape Engine Configuration +BreakEscape.configure do |config| + # Set to true for standalone mode (development) + # Set to false when mounted in Hacktivity (production) + config.standalone_mode = ENV['BREAK_ESCAPE_STANDALONE'] == 'true' + + # Demo user handle for standalone mode + config.demo_user_handle = ENV['BREAK_ESCAPE_DEMO_USER'] || 'demo_player' +end +``` + +**Save and close** + +### 11.5 Test Standalone Mode + +```bash +# Set environment variable +export BREAK_ESCAPE_STANDALONE=true + +# Start server +rails server + +# Visit http://localhost:3000/break_escape/ +# Should work without Hacktivity User model + +# Check demo user created +rails runner "puts BreakEscape::DemoUser.first&.handle" +# Should print: demo_player +``` + +**Expected output:** Standalone mode works + +### 11.6 Commit + +```bash +git add -A +git commit -m "feat: Add standalone mode support + +- Create DemoUser model for standalone development +- Add configuration system (standalone vs mounted) +- Use ENV variables for configuration +- current_player method supports both modes +- Can run without Hacktivity for development" + +git push +``` + +--- + +## Phase 12: Final Integration & Deployment (Week 11-12, ~6 hours) + +### Objectives + +- Final testing of all features +- Create README documentation +- Prepare for Hacktivity integration +- Verify production readiness + +### 12.1 Create Engine README + +```bash +vim README.md +``` + +**Replace with:** + +```markdown +# BreakEscape Rails Engine + +Cybersecurity training escape room game as a mountable Rails Engine. + +## Features + +- 24+ cybersecurity escape room scenarios +- Server-side progress tracking +- Randomized passwords per game instance +- JIT Ink script compilation +- Polymorphic player support (User/DemoUser) +- Pundit authorization +- 2-table simple schema + +## Installation + +In your Gemfile: + +\`\`\`ruby +gem 'break_escape', path: 'path/to/break_escape' +\`\`\` + +Then: + +\`\`\`bash +bundle install +rails break_escape:install:migrations +rails db:migrate +\`\`\` + +## Mounting in Host App + +In your `config/routes.rb`: + +\`\`\`ruby +mount BreakEscape::Engine => "/break_escape" +\`\`\` + +## Usage + +### Standalone Mode (Development) + +\`\`\`bash +export BREAK_ESCAPE_STANDALONE=true +rails server +# Visit http://localhost:3000/break_escape/ +\`\`\` + +### Mounted Mode (Production) + +Mount in Hacktivity or another Rails app. The engine will use the host app's `current_user` via Devise. + +## Configuration + +\`\`\`ruby +# config/initializers/break_escape.rb +BreakEscape.configure do |config| + config.standalone_mode = false # true for development + config.demo_user_handle = 'demo_player' +end +\`\`\` + +## Database Schema + +- `break_escape_missions` - Scenario metadata +- `break_escape_games` - Player state + scenario snapshot +- `break_escape_demo_users` - Standalone mode only (optional) + +## API Endpoints + +- `GET /games/:id/scenario` - Scenario JSON +- `GET /games/:id/ink?npc=X` - NPC script (JIT compiled) +- `GET /games/:id/bootstrap` - Initial game data +- `PUT /games/:id/sync_state` - Sync state +- `POST /games/:id/unlock` - Validate unlock +- `POST /games/:id/inventory` - Update inventory + +## Testing + +\`\`\`bash +rails test +\`\`\` + +## License + +MIT +\`\`\` + +**Save and close** + +### 12.2 Final Test Checklist + +Run through this checklist: + +```bash +# 1. Migrations work +rails db:migrate:reset +rails db:seed + +# 2. Models work +rails runner "puts BreakEscape::Mission.count" +rails runner "m = BreakEscape::Mission.first; puts m.generate_scenario_data.keys" + +# 3. Controllers work +rails server & +curl http://localhost:3000/break_escape/missions +curl http://localhost:3000/break_escape/games/1/scenario + +# 4. Tests pass +rails test + +# 5. Standalone mode works +export BREAK_ESCAPE_STANDALONE=true +rails server +# Visit http://localhost:3000/break_escape/ + +# 6. Game plays end-to-end +# - Select mission +# - Load game +# - Interact with objects +# - Unlock rooms +# - Talk to NPCs +``` + +**Expected output:** All checks pass + +### 12.3 Prepare for Hacktivity Integration + +```bash +# Create integration guide +vim HACKTIVITY_INTEGRATION.md +``` + +**Add:** + +```markdown +# Integrating BreakEscape into Hacktivity + +## Prerequisites + +- Hacktivity running Rails 7.0+ +- PostgreSQL database +- User model with Devise + +## Installation Steps + +### 1. Add to Gemfile + +\`\`\`ruby +# Gemfile +gem 'break_escape', path: '../BreakEscape' +\`\`\` + +### 2. Install and Migrate + +\`\`\`bash +bundle install +rails break_escape:install:migrations +rails db:migrate +\`\`\` + +### 3. Mount Engine + +\`\`\`ruby +# config/routes.rb +mount BreakEscape::Engine => "/break_escape" +\`\`\` + +### 4. Configure + +\`\`\`ruby +# config/initializers/break_escape.rb +BreakEscape.configure do |config| + config.standalone_mode = false # Mounted mode +end +\`\`\` + +### 5. Verify User Model + +Ensure your User model has: +- `admin?` method +- `account_manager?` method (optional) + +### 6. Restart Server + +\`\`\`bash +rails restart +\`\`\` + +### 7. Visit + +Navigate to: https://your-hacktivity.com/break_escape/ + +## Troubleshooting + +- **404 errors:** Check that engine is mounted +- **Auth errors:** Verify Devise current_user works +- **Asset 404s:** Check public/break_escape/ exists +- **Ink errors:** Verify bin/inklecate executable +\`\`\` + +**Save and close** + +### 12.4 Final Commit + +```bash +git add -A +git commit -m "docs: Add README and integration guide + +- Comprehensive README with installation instructions +- Hacktivity integration guide +- Configuration documentation +- API reference +- Testing instructions +- Troubleshooting guide + +Migration complete! Ready for production." + +git push +``` + +### 12.5 Merge to Main + +```bash +# Ensure all tests pass +rails test + +# Merge feature branch +git checkout main +git merge rails-engine-migration +git push origin main + +# Tag release +git tag -a v1.0.0 -m "Rails Engine Migration Complete" +git push origin v1.0.0 +``` + +--- + +## Migration Complete! 🎉 + +### Summary + +**Phases Completed:** +1. ✅ Rails Engine Structure +2. ✅ Move Game Files +3. ✅ Scenario ERB Templates +4. ✅ Database Setup +5. ✅ Seed Data +6. ✅ Controllers & Routes +7. ✅ Authorization Policies +8. ✅ Views +9. ✅ Client Integration +10. ✅ Testing +11. ✅ Standalone Mode +12. ✅ Final Integration + +**Total Time:** ~78 hours over 10-12 weeks + +**What Was Achieved:** +- ✅ Rails Engine with isolated namespace +- ✅ 2-table database schema (missions + games) +- ✅ JIT Ink compilation (~300ms) +- ✅ ERB scenario randomization +- ✅ Polymorphic player (User/DemoUser) +- ✅ Pundit authorization +- ✅ API for game state +- ✅ Minimal client changes (<5%) +- ✅ Comprehensive test suite +- ✅ Standalone mode support +- ✅ Production-ready + +**Next Steps:** +1. Deploy to Hacktivity staging +2. Test in production environment +3. Monitor performance +4. Gather user feedback +5. Iterate and improve + +Congratulations! The migration is complete.