Comprehensive implementation plan for converting BreakEscape to a Rails Engine. DOCUMENTATION CREATED: - 00_OVERVIEW.md: Project aims, philosophy, decisions summary - 01_ARCHITECTURE.md: Technical design, models, controllers, API - 02_IMPLEMENTATION_PLAN.md: Phases 1-6 with bash/rails commands - 02_IMPLEMENTATION_PLAN_PART2.md: Phases 7-12 with client integration - 03_DATABASE_SCHEMA.md: 3-table JSONB schema reference - 04_TESTING_GUIDE.md: Fixtures, tests, CI setup - README.md: Quick start and navigation guide KEY APPROACH: - Simplified JSON-centric storage (3 tables vs 10+) - JSONB for player state (one column, all game data) - Minimal client changes (move files, add API client) - Dual mode: Standalone + Hacktivity integration - Session-based auth with polymorphic player - Pundit policies for authorization - ERB templates for scenario randomization TIMELINE: 12-14 weeks (vs 22 weeks complex approach) ARCHITECTURE DECISIONS: - Static assets in public/break_escape/ - Scenarios in app/assets/scenarios/ with ERB - .ink and .ink.json files organized by scenario - Lazy-load NPC scripts on encounter - Server validates unlocks, client runs dialogue - 6 API endpoints (not 15+) Each phase includes: - Specific bash mv commands - Rails generate and migrate commands - Code examples with manual edits - Testing steps - Git commit points Ready for implementation.
20 KiB
BreakEscape Rails Engine - Implementation Plan
Overview
This is the actionable TODO list for converting BreakEscape to a Rails Engine.
Key Principles:
- ✅ Use
bash mvcommands to move files (don't copy/rewrite) - ✅ Use
rails generateandrails db:migratecommands - ✅ Make manual edits only after generating files
- ✅ Test after each phase
- ✅ Commit after each working step
Estimated Time: 12-14 weeks
Phase 1: Setup Rails Engine Structure (Week 1)
Prerequisites
# Ensure you're in the project directory
cd /home/user/BreakEscape
# Create feature branch
git checkout -b rails-engine-migration
# Commit current state
git add -A
git commit -m "chore: Checkpoint before Rails Engine migration"
1.1 Generate Rails Engine
# Generate mountable engine (creates isolated namespace)
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
Manual edits after generation:
# lib/break_escape/engine.rb
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('lib', __dir__)
# Pundit authorization
config.after_initialize do
BreakEscape::ApplicationController.send(:include, Pundit::Authorization) if defined?(Pundit)
end
# Static files from public/break_escape
config.middleware.use ::ActionDispatch::Static, "#{root}/public"
end
end
# lib/break_escape/version.rb
module BreakEscape
VERSION = '0.1.0'
end
Update Gemfile:
# Gemfile
source 'https://rubygems.org'
gemspec
# Development dependencies
group :development, :test do
gem 'sqlite3'
gem 'pry'
gem 'pry-byebug'
end
# Runtime dependencies
gem 'rails', '~> 7.0'
gem 'pundit', '~> 2.3'
Update gemspec:
# break_escape.gemspec
require_relative "lib/break_escape/version"
Gem::Specification.new do |spec|
spec.name = "break_escape"
spec.version = BreakEscape::VERSION
spec.authors = ["Your Name"]
spec.email = ["your.email@example.com"]
spec.summary = "BreakEscape escape room game engine"
spec.description = "Rails engine for BreakEscape escape room cybersecurity training 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
Install dependencies:
bundle install
Commit:
git add -A
git commit -m "feat: Generate Rails Engine structure"
Phase 2: Move Game Files to public/ (Week 1)
2.1 Create public directory structure
# Create directory
mkdir -p public/break_escape
# Move existing game files (USING MV, NOT COPY!)
mv js public/break_escape/
mv css public/break_escape/
mv assets public/break_escape/
# Keep index.html for reference (but we'll use Rails view)
cp index.html public/break_escape/index.html.backup
Verify files moved correctly:
ls -la public/break_escape/
# Should see: js/ css/ assets/ index.html.backup
Update .gitignore if needed:
# .gitignore should NOT ignore public/break_escape/
# Verify:
git check-ignore public/break_escape/js/
# Should return nothing (not ignored)
Commit:
git add -A
git commit -m "refactor: Move game files to public/break_escape/"
Phase 3: Reorganize Scenarios (Week 1-2)
3.1 Create scenario directory structure
# Create app/assets/scenarios structure
mkdir -p app/assets/scenarios/common/ink
# List current scenarios
ls scenarios/*.json
3.2 Reorganize each scenario
For EACH scenario (ceo_exfil, cybok_heist, etc.):
# Example for ceo_exfil:
SCENARIO="ceo_exfil"
# Create directory
mkdir -p "app/assets/scenarios/${SCENARIO}/ink"
# Move scenario JSON and rename to .erb
mv "scenarios/${SCENARIO}.json" "app/assets/scenarios/${SCENARIO}/scenario.json.erb"
# Move NPC Ink files
# Find all ink files referenced in the scenario
# Example:
mv "scenarios/ink/security_guard.ink" "app/assets/scenarios/${SCENARIO}/ink/"
mv "scenarios/ink/security_guard.ink.json" "app/assets/scenarios/${SCENARIO}/ink/"
# Repeat for each NPC in the scenario
For common/shared Ink files:
# If any ink files are used by multiple scenarios:
mv scenarios/ink/shared_*.ink app/assets/scenarios/common/ink/
mv scenarios/ink/shared_*.ink.json app/assets/scenarios/common/ink/
Manual process (document what you do):
Create a file to track the reorganization:
# scenarios/REORGANIZATION_LOG.md
# Document which files went where
# Example:
# ceo_exfil:
# - scenarios/ceo_exfil.json → app/assets/scenarios/ceo_exfil/scenario.json.erb
# - scenarios/ink/security_guard.* → app/assets/scenarios/ceo_exfil/ink/
# ...
Remove old scenarios directory (after verification):
# ONLY after verifying all files moved:
rm -rf scenarios/
# Or keep for reference:
mv scenarios scenarios.backup
Commit:
git add -A
git commit -m "refactor: Reorganize scenarios into app/assets/scenarios/"
Phase 4: Database Setup (Week 2)
4.1 Generate migrations
# Generate Scenarios table
rails generate migration CreateBreakEscapeScenarios
# Generate NpcScripts table
rails generate migration CreateBreakEscapeNpcScripts
# Generate GameInstances table
rails generate migration CreateBreakEscapeGameInstances
Edit generated migrations:
# db/migrate/xxx_create_break_escape_scenarios.rb
class CreateBreakEscapeScenarios < ActiveRecord::Migration[7.0]
def change
create_table :break_escape_scenarios do |t|
t.string :name, null: false
t.string :display_name, null: false
t.text :description
t.jsonb :scenario_data, null: false
t.boolean :published, default: false
t.integer :difficulty_level, default: 1
t.timestamps
end
add_index :break_escape_scenarios, :name, unique: true
add_index :break_escape_scenarios, :published
add_index :break_escape_scenarios, :scenario_data, using: :gin
end
end
# db/migrate/xxx_create_break_escape_npc_scripts.rb
class CreateBreakEscapeNpcScripts < ActiveRecord::Migration[7.0]
def change
create_table :break_escape_npc_scripts do |t|
t.references :scenario, null: false, foreign_key: { to_table: :break_escape_scenarios }
t.string :npc_id, null: false
t.text :ink_source
t.text :ink_compiled, null: false
t.timestamps
end
add_index :break_escape_npc_scripts, [:scenario_id, :npc_id], unique: true
end
end
# db/migrate/xxx_create_break_escape_game_instances.rb
class CreateBreakEscapeGameInstances < ActiveRecord::Migration[7.0]
def change
create_table :break_escape_game_instances do |t|
# Polymorphic player
t.references :player, polymorphic: true, null: false
# Scenario reference
t.references :scenario, null: false, foreign_key: { to_table: :break_escape_scenarios }
# Player state (JSONB)
t.jsonb :player_state, null: false, default: {
currentRoom: nil,
position: { x: 0, y: 0 },
unlockedRooms: [],
unlockedObjects: [],
inventory: [],
encounteredNPCs: [],
globalVariables: {}
}
# Game metadata
t.string :status, default: 'in_progress'
t.datetime :started_at
t.datetime :completed_at
t.integer :score, default: 0
t.integer :health, default: 100
t.timestamps
end
add_index :break_escape_game_instances,
[:player_type, :player_id, :scenario_id],
unique: true,
name: 'index_game_instances_on_player_and_scenario'
add_index :break_escape_game_instances, :player_state, using: :gin
add_index :break_escape_game_instances, :status
end
end
Run migrations:
rails db:migrate
Commit:
git add -A
git commit -m "feat: Add database schema for scenarios, NPCs, and game instances"
4.2 Generate models
# Generate model files (skeleton only, we'll edit them)
rails generate model Scenario --skip-migration
rails generate model NpcScript --skip-migration
rails generate model GameInstance --skip-migration
Edit models:
# app/models/break_escape/scenario.rb
module BreakEscape
class Scenario < ApplicationRecord
self.table_name = 'break_escape_scenarios'
has_many :game_instances, class_name: 'BreakEscape::GameInstance'
has_many :npc_scripts, class_name: 'BreakEscape::NpcScript'
validates :name, presence: true, uniqueness: true
validates :display_name, presence: true
validates :scenario_data, presence: true
scope :published, -> { where(published: true) }
def start_room
scenario_data['startRoom']
end
def start_room?(room_id)
start_room == room_id
end
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
def validate_unlock(target_type, target_id, attempt, method)
if target_type == 'door'
room = room_data(target_id)
return false unless room
return false unless 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
next unless 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
end
end
# app/models/break_escape/npc_script.rb
module BreakEscape
class NpcScript < ApplicationRecord
self.table_name = 'break_escape_npc_scripts'
belongs_to :scenario, class_name: 'BreakEscape::Scenario'
validates :npc_id, presence: true
validates :ink_compiled, presence: true
validates :npc_id, uniqueness: { scope: :scenario_id }
end
end
# app/models/break_escape/game_instance.rb
module BreakEscape
class GameInstance < ApplicationRecord
self.table_name = 'break_escape_game_instances'
# Polymorphic association
belongs_to :player, polymorphic: true
belongs_to :scenario, class_name: 'BreakEscape::Scenario'
validates :player, presence: true
validates :scenario, presence: true
validates :status, inclusion: { in: %w[in_progress completed abandoned] }
scope :active, -> { where(status: 'in_progress') }
scope :completed, -> { where(status: 'completed') }
before_create :set_started_at
before_create :initialize_player_state
# State management methods
def unlock_room!(room_id)
player_state['unlockedRooms'] ||= []
player_state['unlockedRooms'] << room_id unless player_state['unlockedRooms'].include?(room_id)
save!
end
def unlock_object!(object_id)
player_state['unlockedObjects'] ||= []
player_state['unlockedObjects'] << object_id unless player_state['unlockedObjects'].include?(object_id)
save!
end
def add_inventory_item!(item)
player_state['inventory'] ||= []
player_state['inventory'] << item
save!
end
def remove_inventory_item!(item_id)
player_state['inventory'] ||= []
player_state['inventory'].reject! { |item| item['id'] == item_id }
save!
end
def room_unlocked?(room_id)
player_state['unlockedRooms']&.include?(room_id) || scenario.start_room?(room_id)
end
def object_unlocked?(object_id)
player_state['unlockedObjects']&.include?(object_id)
end
def npc_encountered?(npc_id)
player_state['encounteredNPCs']&.include?(npc_id)
end
def encounter_npc!(npc_id)
player_state['encounteredNPCs'] ||= []
player_state['encounteredNPCs'] << npc_id unless player_state['encounteredNPCs'].include?(npc_id)
save!
end
def update_position!(x, y)
player_state['position'] = { 'x' => x, 'y' => y }
save!
end
def update_global_variable!(key, value)
player_state['globalVariables'] ||= {}
player_state['globalVariables'][key] = value
save!
end
private
def set_started_at
self.started_at ||= Time.current
end
def initialize_player_state
self.player_state ||= {}
self.player_state['currentRoom'] ||= scenario.start_room
self.player_state['unlockedRooms'] ||= [scenario.start_room]
self.player_state['position'] ||= { 'x' => 0, 'y' => 0 }
self.player_state['inventory'] ||= []
self.player_state['unlockedObjects'] ||= []
self.player_state['encounteredNPCs'] ||= []
self.player_state['globalVariables'] ||= {}
end
end
end
Commit:
git add -A
git commit -m "feat: Add Scenario, NpcScript, and GameInstance models"
Phase 5: Scenario Import (Week 2)
5.1 Create scenario loader service
mkdir -p lib/break_escape
Create loader:
# lib/break_escape/scenario_loader.rb
module BreakEscape
class ScenarioLoader
attr_reader :scenario_name
def initialize(scenario_name)
@scenario_name = scenario_name
end
def load
# Load and process ERB template
template_path = Rails.root.join('app/assets/scenarios', scenario_name, 'scenario.json.erb')
raise "Scenario not found: #{scenario_name}" unless File.exist?(template_path)
erb = ERB.new(File.read(template_path))
binding_context = ScenarioBinding.new
JSON.parse(erb.result(binding_context.get_binding))
end
def import!
scenario_data = load
scenario = Scenario.find_or_initialize_by(name: scenario_name)
scenario.assign_attributes(
display_name: scenario_data['scenarioName'] || scenario_name.titleize,
description: scenario_data['scenarioBrief'],
scenario_data: scenario_data,
published: true
)
scenario.save!
# Import NPC scripts
import_npc_scripts!(scenario, scenario_data)
scenario
end
private
def import_npc_scripts!(scenario, scenario_data)
npcs = scenario_data['npcs'] || []
npcs.each do |npc_data|
npc_id = npc_data['id']
# Load Ink files
ink_path = Rails.root.join('app/assets/scenarios', scenario_name, 'ink', "#{npc_id}.ink")
ink_json_path = Rails.root.join('app/assets/scenarios', scenario_name, 'ink', "#{npc_id}.ink.json")
next unless File.exist?(ink_json_path)
npc_script = scenario.npc_scripts.find_or_initialize_by(npc_id: npc_id)
npc_script.ink_source = File.read(ink_path) if File.exist?(ink_path)
npc_script.ink_compiled = File.read(ink_json_path)
npc_script.save!
end
end
# Binding context for ERB processing
class ScenarioBinding
def initialize
@random_password = SecureRandom.alphanumeric(8)
@random_pin = rand(1000..9999).to_s
end
attr_reader :random_password, :random_pin
def get_binding
binding
end
end
end
end
5.2 Create seed file
# db/seeds.rb
puts "Importing scenarios..."
scenarios = Dir.glob(Rails.root.join('app/assets/scenarios', '*')).map do |path|
File.basename(path)
end.reject { |name| name == 'common' }
scenarios.each do |scenario_name|
puts " Importing #{scenario_name}..."
begin
loader = BreakEscape::ScenarioLoader.new(scenario_name)
scenario = loader.import!
puts " ✓ #{scenario.display_name}"
rescue => e
puts " ✗ Error: #{e.message}"
end
end
puts "Done! Imported #{BreakEscape::Scenario.count} scenarios."
Run seeds:
rails db:seed
Verify:
rails console
# Check scenarios loaded
BreakEscape::Scenario.count
BreakEscape::Scenario.pluck(:name)
# Check NPC scripts
BreakEscape::NpcScript.count
Commit:
git add -A
git commit -m "feat: Add scenario loader and import seeds"
Phase 6: Controllers and Routes (Week 3)
6.1 Generate controllers
# Main controllers
rails generate controller break_escape/games
rails generate controller break_escape/scenarios
# API controllers
rails generate controller break_escape/api/games
rails generate controller break_escape/api/rooms
rails generate controller break_escape/api/unlocks
rails generate controller break_escape/api/inventory
rails generate controller break_escape/api/npcs
Edit routes:
# config/routes.rb
BreakEscape::Engine.routes.draw do
# Main game view
resources :games, only: [:show] do
member do
get :play
end
end
# Scenario selection
resources :scenarios, only: [:index, :show]
# API endpoints
namespace :api do
resources :games, only: [] do
member do
get :bootstrap
put :sync_state
post :unlock
post :inventory
end
resources :rooms, only: [:show]
resources :npcs, only: [] do
member do
get :script
end
end
end
end
root to: 'scenarios#index'
end
Edit application controller:
# app/controllers/break_escape/application_controller.rb
module BreakEscape
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
# Pundit authorization
include Pundit::Authorization if defined?(Pundit)
# Helper method to get current player (polymorphic)
def current_player
if BreakEscape.configuration.standalone_mode
# Standalone mode - get/create demo user
@current_player ||= DemoUser.first_or_create!(
handle: BreakEscape.configuration.demo_user['handle'],
role: BreakEscape.configuration.demo_user['role']
)
else
# Mounted mode - use Hacktivity's current_user
current_user
end
end
helper_method :current_player
end
end
Edit games controller:
# app/controllers/break_escape/games_controller.rb
module BreakEscape
class GamesController < ApplicationController
before_action :set_game_instance
def show
@scenario = @game_instance.scenario
authorize @game_instance if defined?(Pundit)
end
alias_method :play, :show
private
def set_game_instance
@game_instance = GameInstance.find(params[:id])
end
end
end
Edit scenarios controller:
# app/controllers/break_escape/scenarios_controller.rb
module BreakEscape
class ScenariosController < ApplicationController
def index
@scenarios = if defined?(Pundit)
policy_scope(Scenario)
else
Scenario.published
end
end
def show
@scenario = Scenario.find(params[:id])
authorize @scenario if defined?(Pundit)
# Create or find game instance
@game_instance = GameInstance.find_or_create_by!(
player: current_player,
scenario: @scenario
)
redirect_to game_path(@game_instance)
end
end
end
Continue with API controllers in next comment (file getting long)...
TO BE CONTINUED...
The implementation plan continues with:
- Phase 6 (continued): API Controllers
- Phase 7: Policies
- Phase 8: Views
- Phase 9: Client Integration
- Phase 10: Testing
- Phase 11: Standalone Mode
- Phase 12: Deployment
Each phase includes specific bash commands, rails generate commands, and code examples.
This is Part 1 of the implementation plan.
See 02_IMPLEMENTATION_PLAN_PART2.md for continuation.