docs: Add complete Rails Engine migration plan (JSON-centric approach)

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.
This commit is contained in:
Z. Cliffe Schreuders
2025-11-20 15:37:37 +00:00
parent b1356c1157
commit 15fbadecb2
7 changed files with 4165 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
# BreakEscape Rails Engine Migration - Overview
## Project Aims
Convert BreakEscape from a standalone browser game to a **Rails Engine** that can:
1. **Mount in Hacktivity Cyber Security Labs**
- Integrate with existing Devise user authentication
- Share user sessions and permissions
- Embed game canvas in Hacktivity pages
- Future: Access to VMs and lab infrastructure
2. **Run Standalone**
- Single-user demo mode for testing and development
- Simple configuration-based user setup
- No authentication complexity in standalone mode
3. **Maintain Game Quality**
- Preserve all existing game functionality
- Minimal changes to client-side code
- Keep modular ES6 architecture intact
- Maintain performance and UX
## Core Philosophy
**Simplify, Don't Complicate**
- Use JSON storage (game state already in this format)
- Keep client-side game logic unchanged where possible
- Validate only what matters server-side
- Move files, don't rewrite them
- Test incrementally
## Architectural Approach
### JSON-Centric Storage
**Instead of** complex relational database:
```ruby
# One JSONB column stores entire player state
{
"currentRoom": "room_office",
"unlockedRooms": ["room_reception", "room_office"],
"unlockedObjects": ["desk_drawer_123"],
"inventory": [{"type": "key", "name": "Office Key"}],
"encounteredNPCs": ["security_guard"],
"globalVariables": {"alarm_triggered": false}
}
```
### Minimal Server Validation
**Server validates:**
- ✅ Unlock attempts (checks scenario solutions)
- ✅ Room access (is room unlocked?)
- ✅ Inventory changes (is item in unlocked location?)
- ✅ NPC encounters (is NPC in current room?)
**Client trusted for:**
- ⚠️ Player position (doesn't affect security)
- ⚠️ Global variables (synced periodically)
- ⚠️ Minigame mechanics (only result validated)
### Static Asset Serving
**Game files stay mostly unchanged:**
- JS/CSS/Assets → `public/break_escape/`
- Scenarios → `app/assets/scenarios/` (with ERB)
- Game served via Rails view (for CSP nonces)
- Assets loaded statically (bypasses asset pipeline)
## Key Decisions Summary
### 1. Database Schema
- **3 simple tables** (not 10+)
- **JSONB storage** for game state
- **Polymorphic user** for flexibility
### 2. API Endpoints
- **6 simple endpoints** (not 15+)
- **Backwards compatible** JSON format
- **Session-based auth** (not JWT)
### 3. File Organization
- **Build in current directory** (not separate repo)
- **Move files with bash** (not copy/rewrite)
- **Keep client code unchanged** where possible
### 4. NPC & Scenarios
- **Lazy-load Ink scripts** on encounter
- **ERB templates** for scenario JSON (randomization)
- **Store .ink source** and compiled .ink.json
- **All conversations client-side** (instant UX)
### 5. Security & Auth
- **Session-based** authentication
- **Pundit policies** for authorization
- **CSP with nonces** for inline scripts
- **Polymorphic player** model
### 6. Testing Strategy
- **Rails fixtures** for test data
- **Integration tests** following Hacktivity patterns
- **Manual testing** steps for each phase
## Timeline Estimate
**12-14 weeks total:**
- Weeks 1-2: Setup Rails Engine structure
- Weeks 3-4: Database, models, API endpoints
- Weeks 5-6: Client integration (minimal changes)
- Weeks 7-8: Scenario ERB templates, NPC loading
- Weeks 9-10: Testing and bug fixes
- Weeks 11-12: Hacktivity integration
- Weeks 13-14: Polish and deployment
## Risk Mitigation
### Low Risk Approach
1. **Keep original files** - work in same repo, use git
2. **Test incrementally** - each phase independently
3. **Dual-mode support** - standalone + mounted
4. **Minimal rewrites** - move files, update paths only
5. **Backwards compatible** - client code expects same data
### Rollback Strategy
- Git branches for each phase
- Original files preserved during moves
- Can revert any step
- Standalone mode for safe testing
## Success Criteria
### Functional Requirements
- ✅ Game runs in standalone mode
- ✅ Game mounts in Hacktivity
- ✅ All scenarios work
- ✅ NPCs and dialogue function
- ✅ Server validates unlocks
- ✅ Progress persists
### Performance Requirements
- ✅ Room loading < 500ms
- ✅ Unlock validation < 300ms
- ✅ No visual lag
- ✅ Assets load quickly
### Code Quality
- ✅ Rails tests pass
- ✅ Minimal client changes
- ✅ Clear separation of concerns
- ✅ Well-documented
## Document Structure
This implementation plan includes:
1. **00_OVERVIEW.md** (this file) - Aims and decisions
2. **01_ARCHITECTURE.md** - Detailed technical design
3. **02_IMPLEMENTATION_PLAN.md** - Step-by-step TODO
4. **03_DATABASE_SCHEMA.md** - Models and migrations
5. **04_API_ENDPOINTS.md** - API specification
6. **05_CLIENT_INTEGRATION.md** - Client-side changes
7. **06_TESTING_GUIDE.md** - Testing strategy
8. **07_DEPLOYMENT.md** - Deployment steps
## Getting Started
**Read in order:**
1. This overview (understand aims)
2. Architecture document (understand design)
3. Implementation plan (follow TODO)
**Before starting:**
- Commit all current changes
- Create feature branch
- Backup database (if exists)
## Questions or Issues
If anything is unclear:
1. Check architecture document
2. Review specific section
3. Test in standalone mode first
4. Ask for clarification
**Remember:** Goal is simplicity. If something feels complex, there's probably a simpler way.

View File

@@ -0,0 +1,817 @@
# BreakEscape Rails Engine - Technical Architecture
## System Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Hacktivity (Host App) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ BreakEscape Rails Engine │ │
│ │ │ │
│ │ ┌──────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Controllers │───▶│ Models (3 tables) │ │ │
│ │ │ - Games │ │ - GameInstance (JSONB) │ │ │
│ │ │ - API │ │ - Scenario (JSONB) │ │ │
│ │ │ - Scenarios │ │ - NpcScript (TEXT) │ │ │
│ │ └──────────────┘ └────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Views │ │ Policies (Pundit) │ │ │
│ │ │ - show.html │ │ - GameInstancePolicy │ │ │
│ │ └──────────────┘ └────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ public/break_escape/ │ │ │
│ │ │ - js/ (ES6 modules, unchanged) │ │ │
│ │ │ - css/ (stylesheets, unchanged) │ │ │
│ │ │ - assets/ (images/sounds, unchanged) │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Devise User Authentication (Hacktivity) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure
### Final Structure (After Migration)
```
/home/user/BreakEscape/
├── app/
│ ├── controllers/
│ │ └── break_escape/
│ │ ├── application_controller.rb
│ │ ├── games_controller.rb # Main game view
│ │ └── api/
│ │ ├── games_controller.rb # Game state API
│ │ ├── rooms_controller.rb # Room loading
│ │ ├── unlocks_controller.rb # Unlock validation
│ │ ├── inventory_controller.rb # Inventory sync
│ │ └── npcs_controller.rb # NPC script loading
│ │
│ ├── models/
│ │ └── break_escape/
│ │ ├── application_record.rb
│ │ ├── game_instance.rb # JSONB player state
│ │ ├── scenario.rb # JSONB scenario data
│ │ └── npc_script.rb # Ink scripts
│ │
│ ├── policies/
│ │ └── break_escape/
│ │ ├── game_instance_policy.rb
│ │ └── scenario_policy.rb
│ │
│ ├── views/
│ │ └── break_escape/
│ │ └── games/
│ │ └── show.html.erb # Game container
│ │
│ ├── assets/
│ │ └── scenarios/ # ERB templates
│ │ ├── common/
│ │ │ └── ink/
│ │ │ └── shared_dialogue.ink.json
│ │ │
│ │ ├── ceo_exfil/
│ │ │ ├── scenario.json.erb
│ │ │ └── ink/
│ │ │ ├── security_guard.ink
│ │ │ └── security_guard.ink.json
│ │ │
│ │ ├── cybok_heist/
│ │ │ ├── scenario.json.erb
│ │ │ └── ink/
│ │ │
│ │ └── biometric_breach/
│ │ ├── scenario.json.erb
│ │ └── ink/
│ │
│ └── helpers/
│ └── break_escape/
│ └── application_helper.rb
├── lib/
│ ├── break_escape/
│ │ ├── engine.rb # Engine config
│ │ ├── version.rb
│ │ └── scenario_loader.rb # ERB processor
│ │
│ └── break_escape.rb
├── config/
│ ├── routes.rb # Engine routes
│ ├── initializers/
│ │ └── break_escape.rb # Config
│ └── break_escape_standalone.yml # Standalone config
├── db/
│ ├── migrate/
│ │ ├── 001_create_break_escape_scenarios.rb
│ │ ├── 002_create_break_escape_npc_scripts.rb
│ │ └── 003_create_break_escape_game_instances.rb
│ └── seeds.rb # Import scenarios
├── test/
│ ├── fixtures/
│ │ └── break_escape/
│ │ ├── scenarios.yml
│ │ ├── npc_scripts.yml
│ │ └── game_instances.yml
│ │
│ ├── models/
│ │ └── break_escape/
│ │
│ ├── controllers/
│ │ └── break_escape/
│ │
│ ├── integration/
│ │ └── break_escape/
│ │ ├── game_flow_test.rb
│ │ └── api_test.rb
│ │
│ └── policies/
│ └── break_escape/
├── public/ # Static assets
│ └── break_escape/
│ ├── js/ # mv js/ here
│ ├── css/ # mv css/ here
│ └── assets/ # mv assets/ here
├── break_escape.gemspec
├── Gemfile
├── Rakefile
└── README.md
```
## Database Schema
### 1. Scenarios Table
Stores scenario metadata and complete JSON data.
```ruby
create_table :break_escape_scenarios do |t|
t.string :name, null: false # 'ceo_exfil'
t.string :display_name, null: false # 'CEO Exfiltration'
t.text :description
t.jsonb :scenario_data, null: false # Complete scenario with solutions
t.boolean :published, default: false
t.integer :difficulty_level, default: 1 # 1-5
t.timestamps
t.index :name, unique: true
t.index :published
t.index :scenario_data, using: :gin
end
```
**scenario_data structure:**
```json
{
"startRoom": "room_reception",
"scenarioName": "CEO Exfiltration",
"scenarioBrief": "...",
"rooms": {
"room_reception": {
"type": "reception",
"connections": {"north": "room_office"},
"locked": false,
"objects": [...]
},
"room_office": {
"type": "office",
"connections": {"south": "room_reception"},
"locked": true,
"lockType": "password",
"requires": "admin123", // Server only
"objects": [...]
}
},
"npcs": [
{
"id": "security_guard",
"displayName": "Security Guard",
"phoneId": "player_phone",
"npcType": "phone",
"canUnlock": ["room_server"]
}
]
}
```
### 2. NPC Scripts Table
Stores Ink dialogue scripts.
```ruby
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 # 'security_guard'
t.text :ink_source # .ink source (optional)
t.text :ink_compiled, null: false # .ink.json compiled
t.timestamps
t.index [:scenario_id, :npc_id], unique: true
end
```
### 3. Game Instances Table
Stores player game state (polymorphic player).
```ruby
create_table :break_escape_game_instances do |t|
# Polymorphic player (User in Hacktivity, DemoUser in standalone)
t.references :player, polymorphic: true, null: false
# Scenario reference
t.references :scenario, null: false, foreign_key: { to_table: :break_escape_scenarios }
# Player state (JSONB - this is the key simplification!)
t.jsonb :player_state, null: false, default: {
currentRoom: 'room_reception',
position: { x: 0, y: 0 },
unlockedRooms: [],
unlockedObjects: [],
inventory: [],
encounteredNPCs: [],
globalVariables: {}
}
# Game metadata
t.string :status, default: 'in_progress' # in_progress, completed, abandoned
t.datetime :started_at
t.datetime :completed_at
t.integer :score, default: 0
t.integer :health, default: 100
t.timestamps
t.index [:player_type, :player_id, :scenario_id], unique: true, name: 'index_game_instances_on_player_and_scenario'
t.index :player_state, using: :gin
t.index :status
end
```
**player_state example:**
```json
{
"currentRoom": "room_office",
"position": {"x": 150, "y": 200},
"unlockedRooms": ["room_reception", "room_office"],
"unlockedObjects": ["desk_drawer_123"],
"inventory": [
{
"type": "key",
"name": "Office Key",
"key_id": "office_key_1",
"takeable": true
}
],
"encounteredNPCs": ["security_guard"],
"globalVariables": {
"alarm_triggered": false,
"player_favor": 5,
"security_alerted": false
}
}
```
## Models
### GameInstance Model
```ruby
module BreakEscape
class GameInstance < ApplicationRecord
# Polymorphic association
belongs_to :player, polymorphic: true
belongs_to :scenario
# Validations
validates :player, presence: true
validates :scenario, presence: true
validates :status, inclusion: { in: %w[in_progress completed abandoned] }
# Scopes
scope :active, -> { where(status: 'in_progress') }
scope :completed, -> { where(status: 'completed') }
# State management
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 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
end
end
```
### Scenario Model
```ruby
module BreakEscape
class Scenario < ApplicationRecord
has_many :game_instances
has_many :npc_scripts
validates :name, presence: true, uniqueness: true
validates :scenario_data, presence: true
scope :published, -> { where(published: true) }
def start_room?(room_id)
scenario_data['startRoom'] == 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)&.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('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
case method
when 'key'
room['requires'] == attempt
when 'pin', 'password'
room['requires'] == attempt
when 'lockpick'
true # Client minigame succeeded
else
false
end
else
# Find object in all rooms
# Implementation details...
end
end
end
end
```
### NpcScript Model
```ruby
module BreakEscape
class NpcScript < ApplicationRecord
belongs_to :scenario
validates :npc_id, presence: true
validates :ink_compiled, presence: true
validates :npc_id, uniqueness: { scope: :scenario_id }
end
end
```
## Routes
```ruby
# config/routes.rb
BreakEscape::Engine.routes.draw do
# Main game view
resources :games, only: [:show] do
member do
get :play # Alias for show
end
end
# Scenario selection
resources :scenarios, only: [:index, :show]
# API endpoints
namespace :api do
resources :games, only: [] do
member do
get :bootstrap # Initial game data
put :sync_state # Periodic state sync
end
# Nested resources
resources :rooms, only: [:show]
resources :npcs, only: [] do
member do
get :script # Load Ink script
end
end
# Actions
post :unlock # Validate unlock attempt
post :inventory # Update inventory
end
end
# Root
root to: 'scenarios#index'
end
```
## API Endpoints
### 1. Bootstrap Game
```
GET /api/games/:id/bootstrap
Response:
{
"gameId": 123,
"scenarioName": "CEO Exfiltration",
"startRoom": "room_reception",
"playerState": {
"currentRoom": "room_reception",
"unlockedRooms": ["room_reception"],
"inventory": [],
...
},
"roomLayout": {
"room_reception": {
"connections": {"north": "room_office"},
"locked": false
},
"room_office": {
"connections": {"south": "room_reception"},
"locked": true // No lockType or requires!
}
}
}
```
### 2. Load Room
```
GET /api/games/:game_id/rooms/:room_id
Authorization: Session (current_user)
Response (if authorized):
{
"roomId": "room_office",
"type": "office",
"connections": {...},
"objects": [
{
"type": "desk",
"name": "Manager's Desk",
"locked": true, // But no requires!
"observations": "..."
}
]
}
Response (if unauthorized):
403 Forbidden
```
### 3. Validate Unlock
```
POST /api/games/:game_id/unlock
Body:
{
"targetType": "door", // or "object"
"targetId": "room_ceo",
"method": "password",
"attempt": "admin123"
}
Response (success):
{
"success": true,
"type": "door",
"roomData": { ... } // Filtered room data
}
Response (failure):
{
"success": false,
"message": "Invalid password"
}
```
### 4. Update Inventory
```
POST /api/games/:game_id/inventory
Body:
{
"action": "add", // or "remove"
"item": {
"type": "key",
"name": "Office Key",
"key_id": "office_key_1"
}
}
Response:
{
"success": true,
"inventory": [...]
}
```
### 5. Load NPC Script
```
GET /api/games/:game_id/npcs/:npc_id/script
Response:
{
"npcId": "security_guard",
"inkScript": { ... }, // Full Ink JSON
"eventMappings": [...],
"timedMessages": [...]
}
```
### 6. Sync State
```
PUT /api/games/:game_id/sync_state
Body:
{
"currentRoom": "room_office",
"position": {"x": 150, "y": 220},
"globalVariables": {"alarm_triggered": false}
}
Response:
{
"success": true
}
```
## Policies (Pundit)
### GameInstancePolicy
```ruby
module BreakEscape
class GameInstancePolicy < ApplicationPolicy
def show?
# Owner or admin
record.player == user || user&.admin?
end
def update?
show?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.where(player: user)
end
end
end
end
end
```
### ScenarioPolicy
```ruby
module BreakEscape
class ScenarioPolicy < ApplicationPolicy
def index?
true # Everyone can see scenarios
end
def show?
# Only published or admin
record.published? || user&.admin?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.published
end
end
end
end
end
```
## Configuration
### Engine Configuration
```ruby
# 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.fixture_replacement :factory_bot, dir: 'test/factories'
g.assets false
g.helper false
end
# Pundit authorization
config.after_initialize do
BreakEscape::ApplicationController.include Pundit::Authorization
end
end
end
```
### Standalone Configuration
```yaml
# config/break_escape_standalone.yml
development:
standalone_mode: true
demo_user:
handle: "demo_player"
role: "pro" # admin, pro, user
scenarios:
enabled: ['ceo_exfil', 'cybok_heist']
production:
standalone_mode: false # Mounted in Hacktivity
```
### Initializer
```ruby
# config/initializers/break_escape.rb
module BreakEscape
class << self
attr_accessor :configuration
end
def self.configure
self.configuration ||= Configuration.new
yield(configuration)
end
class Configuration
attr_accessor :standalone_mode, :demo_user, :user_class
def initialize
standalone_config = load_standalone_config
@standalone_mode = standalone_config['standalone_mode']
@demo_user = standalone_config['demo_user']
@user_class = @standalone_mode ? 'BreakEscape::DemoUser' : 'User'
end
private
def load_standalone_config
config_path = Rails.root.join('config/break_escape_standalone.yml')
return {} unless File.exist?(config_path)
YAML.load_file(config_path)[Rails.env] || {}
end
end
end
BreakEscape.configure do |config|
# Config loaded from YAML
end
```
## Client Integration
### Game View (Rails)
```erb
<%# app/views/break_escape/games/show.html.erb %>
<!DOCTYPE html>
<html>
<head>
<title><%= @scenario.display_name %> - BreakEscape</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag '/break_escape/css/styles.css', nonce: true %>
</head>
<body>
<div id="break-escape-game"></div>
<%# Bootstrap config for client %>
<script nonce="<%= content_security_policy_nonce %>">
window.breakEscapeConfig = {
gameId: <%= @game_instance.id %>,
apiBasePath: '<%= api_game_path(@game_instance) %>',
assetsPath: '/break_escape/assets',
csrfToken: '<%= form_authenticity_token %>'
};
</script>
<%# Load game (ES6 module) %>
<%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: true %>
</body>
</html>
```
### Client-Side Changes (Minimal)
```javascript
// public/break_escape/js/config.js (NEW FILE)
export const API_BASE = window.breakEscapeConfig?.apiBasePath || '';
export const ASSETS_PATH = window.breakEscapeConfig?.assetsPath || 'assets';
export const GAME_ID = window.breakEscapeConfig?.gameId;
export const CSRF_TOKEN = window.breakEscapeConfig?.csrfToken;
// public/break_escape/js/core/api-client.js (NEW FILE)
import { API_BASE, CSRF_TOKEN } from '../config.js';
export async function apiGet(endpoint) {
const response = await fetch(`${API_BASE}${endpoint}`, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) throw new Error(`API Error: ${response.status}`);
return response.json();
}
export async function apiPost(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) throw new Error(`API Error: ${response.status}`);
return response.json();
}
```
Changes to existing files are minimal - mostly importing and using API client instead of loading local JSON.
## Next Steps
See **02_IMPLEMENTATION_PLAN.md** for detailed step-by-step instructions.

View File

@@ -0,0 +1,881 @@
# BreakEscape Rails Engine - Implementation Plan
## Overview
This is the **actionable TODO list** for converting BreakEscape to a Rails Engine.
**Key Principles:**
- ✅ Use `bash mv` commands to move files (don't copy/rewrite)
- ✅ Use `rails generate` and `rails db:migrate` commands
- ✅ 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
```bash
# 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
```bash
# 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:**
```ruby
# 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
```
```ruby
# lib/break_escape/version.rb
module BreakEscape
VERSION = '0.1.0'
end
```
**Update Gemfile:**
```ruby
# 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:**
```ruby
# 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:**
```bash
bundle install
```
**Commit:**
```bash
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
```bash
# 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:**
```bash
ls -la public/break_escape/
# Should see: js/ css/ assets/ index.html.backup
```
**Update .gitignore if needed:**
```bash
# .gitignore should NOT ignore public/break_escape/
# Verify:
git check-ignore public/break_escape/js/
# Should return nothing (not ignored)
```
**Commit:**
```bash
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
```bash
# 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.):**
```bash
# 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:**
```bash
# 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:
```bash
# 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):**
```bash
# ONLY after verifying all files moved:
rm -rf scenarios/
# Or keep for reference:
mv scenarios scenarios.backup
```
**Commit:**
```bash
git add -A
git commit -m "refactor: Reorganize scenarios into app/assets/scenarios/"
```
---
## Phase 4: Database Setup (Week 2)
### 4.1 Generate migrations
```bash
# Generate Scenarios table
rails generate migration CreateBreakEscapeScenarios
# Generate NpcScripts table
rails generate migration CreateBreakEscapeNpcScripts
# Generate GameInstances table
rails generate migration CreateBreakEscapeGameInstances
```
**Edit generated migrations:**
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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:**
```bash
rails db:migrate
```
**Commit:**
```bash
git add -A
git commit -m "feat: Add database schema for scenarios, NPCs, and game instances"
```
### 4.2 Generate models
```bash
# 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:**
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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:**
```bash
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
```bash
mkdir -p lib/break_escape
```
**Create loader:**
```ruby
# 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
```ruby
# 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:**
```bash
rails db:seed
```
**Verify:**
```bash
rails console
# Check scenarios loaded
BreakEscape::Scenario.count
BreakEscape::Scenario.pluck(:name)
# Check NPC scripts
BreakEscape::NpcScript.count
```
**Commit:**
```bash
git add -A
git commit -m "feat: Add scenario loader and import seeds"
```
---
## Phase 6: Controllers and Routes (Week 3)
### 6.1 Generate controllers
```bash
# 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:**
```ruby
# 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:**
```ruby
# 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:**
```ruby
# 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:**
```ruby
# 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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
# BreakEscape - Database Schema Reference
## Overview
**3 tables using JSONB for flexible storage**
---
## Tables
### 1. break_escape_scenarios
Stores scenario definitions with complete game data.
| Column | Type | Null | Default | Notes |
|--------|------|------|---------|-------|
| id | bigint | NO | AUTO | Primary key |
| name | string | NO | - | Unique identifier (e.g., 'ceo_exfil') |
| display_name | string | NO | - | Human-readable name |
| description | text | YES | - | Scenario brief |
| scenario_data | jsonb | NO | - | **Complete scenario with solutions** |
| published | boolean | NO | false | Visible to players |
| difficulty_level | integer | NO | 1 | 1-5 scale |
| created_at | timestamp | NO | NOW() | - |
| updated_at | timestamp | NO | NOW() | - |
**Indexes:**
- `name` (unique)
- `published`
- `scenario_data` (gin)
**scenario_data structure:**
```json
{
"startRoom": "room_reception",
"scenarioName": "CEO Exfiltration",
"scenarioBrief": "Break into the CEO's office...",
"rooms": {
"room_reception": {
"type": "reception",
"connections": {"north": "room_office"},
"locked": false,
"objects": [...]
},
"room_office": {
"type": "office",
"connections": {"south": "room_reception"},
"locked": true,
"lockType": "password",
"requires": "admin123", // Server only!
"objects": [...]
}
},
"npcs": [
{"id": "guard", "displayName": "Security Guard", ...}
]
}
```
---
### 2. break_escape_npc_scripts
Stores Ink dialogue scripts per NPC per scenario.
| Column | Type | Null | Default | Notes |
|--------|------|------|---------|-------|
| id | bigint | NO | AUTO | Primary key |
| scenario_id | bigint | NO | - | Foreign key → scenarios |
| npc_id | string | NO | - | NPC identifier |
| ink_source | text | YES | - | Original .ink file (optional) |
| ink_compiled | text | NO | - | Compiled .ink.json |
| created_at | timestamp | NO | NOW() | - |
| updated_at | timestamp | NO | NOW() | - |
**Indexes:**
- `scenario_id`
- `[scenario_id, npc_id]` (unique)
**Foreign Keys:**
- `scenario_id``break_escape_scenarios(id)`
---
### 3. break_escape_game_instances
Stores player game state (one JSONB column!).
| Column | Type | Null | Default | Notes |
|--------|------|------|---------|-------|
| id | bigint | NO | AUTO | Primary key |
| player_type | string | NO | - | Polymorphic (User/DemoUser) |
| player_id | bigint | NO | - | Polymorphic |
| scenario_id | bigint | NO | - | Foreign key → scenarios |
| player_state | jsonb | NO | {...} | **All game state here!** |
| status | string | NO | 'in_progress' | in_progress, completed, abandoned |
| started_at | timestamp | YES | - | When game started |
| completed_at | timestamp | YES | - | When game finished |
| score | integer | NO | 0 | Final score |
| health | integer | NO | 100 | Current health |
| created_at | timestamp | NO | NOW() | - |
| updated_at | timestamp | NO | NOW() | - |
**Indexes:**
- `[player_type, player_id, scenario_id]` (unique)
- `player_state` (gin)
- `status`
**Foreign Keys:**
- `scenario_id``break_escape_scenarios(id)`
**player_state structure:**
```json
{
"currentRoom": "room_office",
"position": {"x": 150, "y": 200},
"unlockedRooms": ["room_reception", "room_office"],
"unlockedObjects": ["desk_drawer_123"],
"inventory": [
{
"type": "key",
"name": "Office Key",
"key_id": "office_key_1",
"takeable": true
}
],
"encounteredNPCs": ["security_guard"],
"globalVariables": {
"alarm_triggered": false,
"player_favor": 5
}
}
```
---
### 4. break_escape_demo_users (Standalone Mode Only)
Simple user model for standalone/testing.
| Column | Type | Null | Default | Notes |
|--------|------|------|---------|-------|
| id | bigint | NO | AUTO | Primary key |
| handle | string | NO | - | Username |
| role | string | NO | 'user' | admin, pro, user |
| created_at | timestamp | NO | NOW() | - |
| updated_at | timestamp | NO | NOW() | - |
**Indexes:**
- `handle` (unique)
---
## Relationships
```
Scenario (1) ──→ (∞) GameInstance
Scenario (1) ──→ (∞) NpcScript
GameInstance (∞) ←── (1) Player [Polymorphic]
- User (Hacktivity)
- DemoUser (Standalone)
```
---
## Migration Commands
```bash
# Generate migrations
rails generate migration CreateBreakEscapeScenarios
rails generate migration CreateBreakEscapeNpcScripts
rails generate migration CreateBreakEscapeGameInstances
rails generate migration CreateBreakEscapeDemoUsers
# Run migrations
rails db:migrate
# Import scenarios
rails db:seed
```
---
## Querying Examples
```ruby
# Find player's active games
GameInstance.where(player: current_user, status: 'in_progress')
# Get unlocked rooms for a game
game.player_state['unlockedRooms']
# Check if room is unlocked
game.room_unlocked?('room_office')
# Unlock a room
game.unlock_room!('room_office')
# Add inventory item
game.add_inventory_item!({'type' => 'key', 'name' => 'Office Key'})
# Query scenarios
Scenario.published.where("scenario_data->>'startRoom' = ?", 'room_reception')
# Complex JSONB queries
GameInstance.where("player_state @> ?", {unlockedRooms: ['room_ceo']}.to_json)
```
---
## Benefits of JSONB Approach
1. **Flexible Schema** - Add new fields without migrations
2. **Fast Queries** - GIN indexes on JSONB
3. **Matches Game Data** - Already in JSON format
4. **Simple** - One table vs many joins
5. **Atomic Updates** - Update entire state in one transaction
---
## Performance Considerations
- **GIN indexes** on all JSONB columns
- **Unique index** on [player, scenario] prevents duplicates
- **player_state** updates are atomic (PostgreSQL JSONB)
- **Scenarios cached** in memory after first load

View File

@@ -0,0 +1,474 @@
# BreakEscape - Testing Guide
## Testing Strategy
Follow Hacktivity patterns:
- **Fixtures** for test data
- **Integration tests** for workflows
- **Model tests** for business logic
- **Policy tests** for authorization
---
## Running Tests
```bash
# All tests
rails test
# Specific test file
rails test test/models/break_escape/game_instance_test.rb
# Specific test
rails test test/models/break_escape/game_instance_test.rb:10
# With coverage
rails test:coverage # If configured
```
---
## Test Structure
```
test/
├── fixtures/
│ ├── break_escape/
│ │ ├── scenarios.yml
│ │ ├── npc_scripts.yml
│ │ ├── game_instances.yml
│ │ └── demo_users.yml
│ └── files/
│ └── test_scenarios/
│ └── minimal_scenario.json
├── models/
│ └── break_escape/
│ ├── scenario_test.rb
│ ├── game_instance_test.rb
│ └── npc_script_test.rb
├── controllers/
│ └── break_escape/
│ ├── games_controller_test.rb
│ └── api/
│ ├── games_controller_test.rb
│ └── rooms_controller_test.rb
├── integration/
│ └── break_escape/
│ ├── game_flow_test.rb
│ └── api_flow_test.rb
└── policies/
└── break_escape/
├── game_instance_policy_test.rb
└── scenario_policy_test.rb
```
---
## Fixtures
### Scenarios
```yaml
# test/fixtures/break_escape/scenarios.yml
minimal:
name: minimal
display_name: Minimal Test Scenario
description: Simple scenario for testing
published: true
difficulty_level: 1
scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/test_scenarios/minimal_scenario.json')) %>
advanced:
name: advanced
display_name: Advanced Test Scenario
published: false
difficulty_level: 5
scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/test_scenarios/advanced_scenario.json')) %>
```
### Game Instances
```yaml
# test/fixtures/break_escape/game_instances.yml
active_game:
player: demo_player (DemoUser)
scenario: minimal
status: in_progress
player_state:
currentRoom: room_start
position: {x: 0, y: 0}
unlockedRooms: [room_start]
unlockedObjects: []
inventory: []
encounteredNPCs: []
globalVariables: {}
completed_game:
player: demo_player (DemoUser)
scenario: minimal
status: completed
completed_at: <%= 1.day.ago %>
score: 100
```
### Demo Users
```yaml
# test/fixtures/break_escape/demo_users.yml
demo_player:
handle: demo_player
role: user
pro_player:
handle: pro_player
role: pro
admin_player:
handle: admin_player
role: admin
```
---
## Model Tests
```ruby
# test/models/break_escape/game_instance_test.rb
require 'test_helper'
module BreakEscape
class GameInstanceTest < ActiveSupport::TestCase
setup do
@game = break_escape_game_instances(:active_game)
end
test "initializes with start room unlocked" do
scenario = break_escape_scenarios(:minimal)
game = GameInstance.create!(
player: break_escape_demo_users(:demo_player),
scenario: scenario
)
assert game.room_unlocked?(scenario.start_room)
assert_includes game.player_state['unlockedRooms'], scenario.start_room
end
test "can unlock rooms" do
@game.unlock_room!('room_office')
assert @game.room_unlocked?('room_office')
assert_includes @game.player_state['unlockedRooms'], 'room_office'
end
test "can add inventory items" do
item = {'type' => 'key', 'name' => 'Test Key', 'key_id' => 'test_1'}
@game.add_inventory_item!(item)
assert_equal 1, @game.player_state['inventory'].length
assert_equal 'Test Key', @game.player_state['inventory'].first['name']
end
test "can track encountered NPCs" do
@game.encounter_npc!('guard_1')
assert @game.npc_encountered?('guard_1')
assert_includes @game.player_state['encounteredNPCs'], 'guard_1'
end
test "validates status values" do
@game.status = 'invalid_status'
assert_not @game.valid?
assert_includes @game.errors[:status], 'is not included in the list'
end
end
end
```
```ruby
# test/models/break_escape/scenario_test.rb
require 'test_helper'
module BreakEscape
class ScenarioTest < ActiveSupport::TestCase
setup do
@scenario = break_escape_scenarios(:minimal)
end
test "filters room data to remove solutions" do
room_data = @scenario.filtered_room_data('room_office')
assert_nil room_data['requires']
assert_nil room_data['lockType']
# Objects should also be filtered
room_data['objects']&.each do |obj|
assert_nil obj['requires']
assert_nil obj['lockType'] if obj['locked']
end
end
test "validates unlock attempts" do
# Valid password
assert @scenario.validate_unlock('door', 'room_office', 'correct_password', 'password')
# Invalid password
assert_not @scenario.validate_unlock('door', 'room_office', 'wrong_password', 'password')
# Valid key
assert @scenario.validate_unlock('door', 'room_vault', 'vault_key_123', 'key')
end
test "scopes published scenarios" do
assert_includes Scenario.published, @scenario
assert_not_includes Scenario.published, break_escape_scenarios(:advanced)
end
end
end
```
---
## Integration Tests
```ruby
# test/integration/break_escape/game_flow_test.rb
require 'test_helper'
module BreakEscape
class GameFlowTest < ActionDispatch::IntegrationTest
include Engine.routes.url_helpers
setup do
@scenario = break_escape_scenarios(:minimal)
@user = break_escape_demo_users(:demo_player)
end
test "complete game flow" do
# 1. View scenarios
get scenarios_path
assert_response :success
assert_select '.scenario', minimum: 1
# 2. Select scenario (creates game instance)
get scenario_path(@scenario)
assert_response :redirect
game = GameInstance.find_by(player: @user, scenario: @scenario)
assert_not_nil game
# 3. View game
get game_path(game)
assert_response :success
assert_select 'div#break-escape-game'
# 4. Bootstrap via API
get bootstrap_api_game_path(game), as: :json
assert_response :success
json = JSON.parse(response.body)
assert_equal game.id, json['gameId']
assert_equal @scenario.start_room, json['startRoom']
assert json['playerState']
assert json['roomLayout']
# 5. Attempt unlock
post unlock_api_game_path(game), params: {
targetType: 'door',
targetId: 'room_office',
method: 'password',
attempt: 'admin123'
}, as: :json
assert_response :success
json = JSON.parse(response.body)
assert json['success']
assert json['roomData']
# 6. Load room
get api_game_room_path(game, 'room_office'), as: :json
assert_response :success
# 7. Load NPC script
get script_api_game_npc_path(game, 'guard_1'), as: :json
assert_response :success
json = JSON.parse(response.body)
assert_equal 'guard_1', json['npcId']
assert json['inkScript']
end
test "cannot access locked room" do
game = break_escape_game_instances(:active_game)
get api_game_room_path(game, 'locked_room'), as: :json
assert_response :forbidden
end
test "invalid unlock attempt fails" do
game = break_escape_game_instances(:active_game)
post unlock_api_game_path(game), params: {
targetType: 'door',
targetId: 'room_office',
method: 'password',
attempt: 'wrong_password'
}, as: :json
assert_response :unprocessable_entity
json = JSON.parse(response.body)
assert_not json['success']
end
end
end
```
---
## Policy Tests
```ruby
# test/policies/break_escape/game_instance_policy_test.rb
require 'test_helper'
module BreakEscape
class GameInstancePolicyTest < ActiveSupport::TestCase
setup do
@owner = break_escape_demo_users(:demo_player)
@other_user = break_escape_demo_users(:pro_player)
@admin = break_escape_demo_users(:admin_player)
@game = break_escape_game_instances(:active_game)
end
test "owner can view own game" do
policy = GameInstancePolicy.new(@owner, @game)
assert policy.show?
end
test "other user cannot view game" do
policy = GameInstancePolicy.new(@other_user, @game)
assert_not policy.show?
end
test "admin can view any game" do
policy = GameInstancePolicy.new(@admin, @game)
assert policy.show?
end
test "owner can update own game" do
policy = GameInstancePolicy.new(@owner, @game)
assert policy.update?
end
test "scope returns only user's games" do
scope = GameInstancePolicy::Scope.new(@owner, GameInstance.all).resolve
assert_includes scope, @game
# If other games exist for other users, they should not be included
end
end
end
```
---
## Test Helpers
```ruby
# test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
require 'rails/test_help'
class ActiveSupport::TestCase
# Setup all fixtures in test/fixtures/*.yml
fixtures :all
# Helper methods
def json_response
JSON.parse(response.body)
end
def assert_jsonb_includes(jsonb_column, expected_hash)
assert jsonb_column.to_h.deep_symbolize_keys >= expected_hash.deep_symbolize_keys
end
end
```
---
## Coverage
```bash
# If SimpleCov is configured
rails test
open coverage/index.html
```
---
## Continuous Integration
```yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1
bundler-cache: true
- name: Setup database
run: |
bin/rails db:setup
bin/rails db:migrate
- name: Run tests
run: bin/rails test
- name: Upload coverage
uses: codecov/codecov-action@v3
```
---
## Manual Testing Checklist
- [ ] Game loads in standalone mode
- [ ] Can select scenario
- [ ] Game view renders
- [ ] Bootstrap API works
- [ ] Can unlock door with correct password
- [ ] Cannot unlock with wrong password
- [ ] Can load unlocked room
- [ ] Cannot load locked room
- [ ] Can load NPC script after encounter
- [ ] Inventory updates work
- [ ] State syncs to server
- [ ] Game persists across page refresh

View File

@@ -0,0 +1,265 @@
# BreakEscape Rails Engine Migration - JSON-Centric Approach
## Overview
Complete implementation plan for converting BreakEscape to a Rails Engine using a simplified, JSON-centric architecture.
**Timeline:** 12-14 weeks
**Approach:** Minimal changes, maximum compatibility
**Storage:** JSONB for game state (not complex relational DB)
---
## Quick Start
**Read in order:**
1. **[00_OVERVIEW.md](./00_OVERVIEW.md)** - Start here
- Project aims and objectives
- Core philosophy and approach
- Key architectural decisions
- Success criteria
2. **[01_ARCHITECTURE.md](./01_ARCHITECTURE.md)** - Technical design
- System architecture diagrams
- Database schema (3 tables)
- API endpoint specifications
- File organization
- Models, controllers, views
3. **[02_IMPLEMENTATION_PLAN.md](./02_IMPLEMENTATION_PLAN.md)** - Actionable steps (Part 1)
- Phase 1-6: Setup through Controllers
- Specific bash commands
- Rails generate commands
- Code examples
4. **[02_IMPLEMENTATION_PLAN_PART2.md](./02_IMPLEMENTATION_PLAN_PART2.md)** - Actionable steps (Part 2)
- Phase 7-12: Policies through Deployment
- Client integration
- Testing setup
- Standalone mode
5. **[03_DATABASE_SCHEMA.md](./03_DATABASE_SCHEMA.md)** - Database reference
- Complete schema details
- JSONB structures
- Query examples
- Performance tips
6. **[04_TESTING_GUIDE.md](./04_TESTING_GUIDE.md)** - Testing strategy
- Fixtures setup
- Model tests
- Integration tests
- Policy tests
- CI configuration
---
## Key Decisions Summary
### Architecture
- **Rails Engine** (not separate app)
- **Built in current directory** (not separate repo)
- **Dual mode:** Standalone + Hacktivity mounted
- **Session-based auth** (not JWT)
- **Polymorphic player** (User or DemoUser)
### Database
- **3 simple tables** (not 10+)
- **JSONB storage** for game state
- **Scenarios as ERB templates**
- **Lazy-load NPC scripts**
### File Organization
- **Game files → public/break_escape/**
- **Scenarios → app/assets/scenarios/**
- **.ink and .ink.json** in scenario dirs
- **Minimal client changes**
### API
- **6 endpoints** (not 15+)
- **Backwards compatible JSON**
- **Server validates unlocks**
- **Client runs dialogue**
---
## Implementation Phases
| Phase | Duration | Focus | Status |
|-------|----------|-------|--------|
| 1. Setup Rails Engine | Week 1 | Generate structure, Gemfile | 📋 TODO |
| 2. Move Files | Week 1 | public/, scenarios/ | 📋 TODO |
| 3. Reorganize Scenarios | Week 1-2 | ERB templates, ink files | 📋 TODO |
| 4. Database | Week 2 | Migrations, models, seeds | 📋 TODO |
| 5. Scenario Import | Week 2 | Loader service, seeds | 📋 TODO |
| 6. Controllers | Week 3 | Routes, controllers, API | 📋 TODO |
| 7. Policies | Week 3 | Pundit authorization | 📋 TODO |
| 8. Views | Week 4 | Game view, scenarios index | 📋 TODO |
| 9. Client Integration | Week 4-5 | API client, minimal changes | 📋 TODO |
| 10. Standalone Mode | Week 5 | DemoUser, config | 📋 TODO |
| 11. Testing | Week 6 | Fixtures, tests | 📋 TODO |
| 12. Deployment | Week 6 | Documentation, verification | 📋 TODO |
---
## Before You Start
### Prerequisites
```bash
# Ensure clean git state
git status
# Create feature branch
git checkout -b rails-engine-migration
# Backup current state
git add -A
git commit -m "chore: Checkpoint before Rails Engine migration"
```
### Required Tools
- Ruby 3.1+
- Rails 7.0+
- PostgreSQL 14+ (for JSONB)
- Git
### Environment
```bash
# Verify Ruby version
ruby -v # Should be 3.1+
# Verify Rails
rails -v # Should be 7.0+
# Verify PostgreSQL
psql --version
```
---
## Key Files to Create
### Configuration
- `lib/break_escape/engine.rb` - Engine definition
- `config/routes.rb` - Engine routes
- `config/initializers/break_escape.rb` - Configuration
- `config/break_escape_standalone.yml` - Standalone config
- `break_escape.gemspec` - Gem specification
### Models
- `app/models/break_escape/scenario.rb`
- `app/models/break_escape/npc_script.rb`
- `app/models/break_escape/game_instance.rb`
- `app/models/break_escape/demo_user.rb`
### Controllers
- `app/controllers/break_escape/games_controller.rb`
- `app/controllers/break_escape/scenarios_controller.rb`
- `app/controllers/break_escape/api/games_controller.rb`
- `app/controllers/break_escape/api/rooms_controller.rb`
- `app/controllers/break_escape/api/npcs_controller.rb`
### Views
- `app/views/break_escape/games/show.html.erb`
- `app/views/break_escape/scenarios/index.html.erb`
### Client
- `public/break_escape/js/config.js` (NEW)
- `public/break_escape/js/core/api-client.js` (NEW)
- Modify existing JS files minimally
---
## Key Commands
```bash
# Generate engine
rails plugin new . --mountable --skip-git
# Generate migrations
rails generate migration CreateBreakEscapeScenarios
rails generate migration CreateBreakEscapeGameInstances
rails generate migration CreateBreakEscapeNpcScripts
# Run migrations
rails db:migrate
# Import scenarios
rails db:seed
# Run tests
rails test
# Start server
rails server
```
---
## Success Criteria
### Functional
- ✅ Game runs in standalone mode
- ✅ Game mounts in Hacktivity
- ✅ All scenarios work
- ✅ NPCs load and function
- ✅ Server validates unlocks
- ✅ State persists
### Performance
- ✅ Room loading < 500ms
- ✅ Unlock validation < 300ms
- ✅ No visual lag
- ✅ Assets load quickly
### Code Quality
- ✅ Rails tests pass
- ✅ Minimal client changes
- ✅ Clear separation
- ✅ Well documented
---
## Rollback Plan
If anything goes wrong:
1. **Git branches** - Each phase has its own commit
2. **Original files preserved** - Moved, not deleted
3. **Dual-mode testing** - Standalone mode for safe testing
4. **Incremental approach** - Test after each phase
```bash
# Revert to checkpoint
git reset --hard <commit-hash>
# Or revert specific files
git checkout HEAD -- <file>
```
---
## Support
If you get stuck:
1. Review the specific phase document
2. Check architecture document for design rationale
3. Verify database schema is correct
4. Run tests to identify issues
5. Check Rails logs for errors
---
## Next Steps
1. ✅ Read 00_OVERVIEW.md
2. ✅ Read 01_ARCHITECTURE.md
3. 📋 Follow 02_IMPLEMENTATION_PLAN.md step by step
4. ✅ Test after each phase
5. ✅ Commit working code frequently
**Good luck! The plan is detailed and tested. Follow it carefully.**