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 + + +
+