diff --git a/planning_notes/rails-engine-migration-json/00_OVERVIEW.md b/planning_notes/rails-engine-migration-json/00_OVERVIEW.md new file mode 100644 index 0000000..b00e94a --- /dev/null +++ b/planning_notes/rails-engine-migration-json/00_OVERVIEW.md @@ -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. diff --git a/planning_notes/rails-engine-migration-json/01_ARCHITECTURE.md b/planning_notes/rails-engine-migration-json/01_ARCHITECTURE.md new file mode 100644 index 0000000..0da095e --- /dev/null +++ b/planning_notes/rails-engine-migration-json/01_ARCHITECTURE.md @@ -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 %> + + + + <%= @scenario.display_name %> - BreakEscape + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag '/break_escape/css/styles.css', nonce: true %> + + +
+ + <%# Bootstrap config for client %> + + + <%# Load game (ES6 module) %> + <%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: true %> + + +``` + +### 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. diff --git a/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN.md b/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6262190 --- /dev/null +++ b/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN.md @@ -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. diff --git a/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN_PART2.md b/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN_PART2.md new file mode 100644 index 0000000..4c1b288 --- /dev/null +++ b/planning_notes/rails-engine-migration-json/02_IMPLEMENTATION_PLAN_PART2.md @@ -0,0 +1,1312 @@ +# BreakEscape Rails Engine - Implementation Plan (Part 2) + +**Continued from 02_IMPLEMENTATION_PLAN.md** + +--- + +## Phase 6 (Continued): API Controllers (Week 3) + +### 6.2 API Games Controller + +```ruby +# app/controllers/break_escape/api/games_controller.rb +module BreakEscape + module Api + class GamesController < ApplicationController + before_action :set_game_instance + + # GET /api/games/:id/bootstrap + def bootstrap + authorize @game_instance if defined?(Pundit) + + render json: { + gameId: @game_instance.id, + scenarioName: @game_instance.scenario.display_name, + startRoom: @game_instance.scenario.start_room, + playerState: @game_instance.player_state, + roomLayout: build_room_layout + } + end + + # PUT /api/games/:id/sync_state + def sync_state + authorize @game_instance if defined?(Pundit) + + # Update player state (partial update) + @game_instance.player_state.merge!(sync_params) + @game_instance.save! + + render json: { success: true } + end + + # POST /api/games/:id/unlock + def unlock + authorize @game_instance if defined?(Pundit) + + target_type = params[:targetType] # 'door' or 'object' + target_id = params[:targetId] + attempt = params[:attempt] + method = params[:method] + + # Validate with scenario + is_valid = @game_instance.scenario.validate_unlock( + target_type, + target_id, + attempt, + method + ) + + if is_valid + if target_type == 'door' + @game_instance.unlock_room!(target_id) + room_data = @game_instance.scenario.filtered_room_data(target_id) + + render json: { + success: true, + type: 'door', + roomData: room_data + } + else + @game_instance.unlock_object!(target_id) + # Get object contents from scenario + contents = find_object_contents(target_id) + + render json: { + success: true, + type: 'object', + contents: contents + } + end + else + render json: { + success: false, + message: 'Invalid attempt' + }, status: :unprocessable_entity + end + end + + # POST /api/games/:id/inventory + def inventory + authorize @game_instance if defined?(Pundit) + + action = params[:action] # 'add' or 'remove' + item = params[:item] + + case action + when 'add' + # Validate item exists in unlocked location + if validate_item_accessible(item) + @game_instance.add_inventory_item!(item) + render json: { success: true, inventory: @game_instance.player_state['inventory'] } + else + render json: { success: false, message: 'Item not accessible' }, status: :forbidden + end + when 'remove' + @game_instance.remove_inventory_item!(item['id']) + render json: { success: true, inventory: @game_instance.player_state['inventory'] } + else + render json: { success: false, message: 'Invalid action' }, status: :bad_request + end + end + + private + + def set_game_instance + @game_instance = GameInstance.find(params[:id]) + end + + def sync_params + params.permit(:currentRoom, position: [:x, :y], globalVariables: {}) + end + + def build_room_layout + # Return all room connections but no lock details + layout = {} + @game_instance.scenario.scenario_data['rooms'].each do |room_id, room_data| + layout[room_id] = { + connections: room_data['connections'], + locked: room_data['locked'] || false + # Deliberately exclude lockType and requires + } + end + layout + end + + def find_object_contents(object_id) + # Search all rooms for this object + @game_instance.scenario.scenario_data['rooms'].each do |_room_id, room_data| + object = room_data['objects']&.find { |obj| obj['id'] == object_id } + return object['contents'] if object + end + [] + end + + def validate_item_accessible(item) + # Check if item is in an unlocked room/object + # Simplified: trust client for now, add validation later if needed + true + end + end + end +end +``` + +### 6.3 API Rooms Controller + +```ruby +# app/controllers/break_escape/api/rooms_controller.rb +module BreakEscape + module Api + class RoomsController < ApplicationController + before_action :set_game_instance + before_action :set_room + + # GET /api/games/:game_id/rooms/:id + def show + authorize @game_instance if defined?(Pundit) + + # Check if room is unlocked + unless @game_instance.room_unlocked?(params[:id]) + render json: { error: 'Room not unlocked' }, status: :forbidden + return + end + + render json: @game_instance.scenario.filtered_room_data(params[:id]) + end + + private + + def set_game_instance + @game_instance = GameInstance.find(params[:game_id]) + end + + def set_room + @room_id = params[:id] + end + end + end +end +``` + +### 6.4 API NPCs Controller + +```ruby +# app/controllers/break_escape/api/npcs_controller.rb +module BreakEscape + module Api + class NpcsController < ApplicationController + before_action :set_game_instance + before_action :set_npc + + # GET /api/games/:game_id/npcs/:id/script + def script + authorize @game_instance if defined?(Pundit) + + # Check if player has encountered this NPC + # (Either in current room OR already encountered) + unless can_access_npc? + render json: { error: 'NPC not accessible' }, status: :forbidden + return + end + + # Mark as encountered + @game_instance.encounter_npc!(params[:id]) unless @game_instance.npc_encountered?(params[:id]) + + # Load NPC script + npc_script = @game_instance.scenario.npc_scripts.find_by(npc_id: params[:id]) + unless npc_script + render json: { error: 'NPC script not found' }, status: :not_found + return + end + + # Get NPC data from scenario + npc_data = @game_instance.scenario.scenario_data['npcs']&.find { |npc| npc['id'] == params[:id] } + + render json: { + npcId: params[:id], + inkScript: JSON.parse(npc_script.ink_compiled), + eventMappings: npc_data&.dig('eventMappings') || [], + timedMessages: npc_data&.dig('timedMessages') || [] + } + end + + private + + def set_game_instance + @game_instance = GameInstance.find(params[:game_id]) + end + + def set_npc + @npc_id = params[:id] + end + + def can_access_npc? + # NPC is accessible if already encountered OR in current room + return true if @game_instance.npc_encountered?(@npc_id) + + # Check if NPC is in current room + current_room = @game_instance.player_state['currentRoom'] + room_data = @game_instance.scenario.scenario_data['rooms'][current_room] + npc_in_room = room_data&.dig('npcs')&.include?(@npc_id) + + npc_in_room || false + end + end + end +end +``` + +**Commit:** + +```bash +git add -A +git commit -m "feat: Add API controllers for game state, rooms, and NPCs" +``` + +--- + +## Phase 7: Policies (Week 3) + +### 7.1 Generate policies + +```bash +# Create policies directory +mkdir -p app/policies/break_escape + +# Generate policy files +rails generate pundit:policy break_escape/game_instance +rails generate pundit:policy break_escape/scenario +``` + +**Edit policies:** + +```ruby +# app/policies/break_escape/game_instance_policy.rb +module BreakEscape + class GameInstancePolicy < ApplicationPolicy + def show? + owner_or_admin? + end + + def update? + owner_or_admin? + end + + def destroy? + owner_or_admin? + end + + class Scope < Scope + def resolve + if user&.admin? + scope.all + else + scope.where(player: user) + end + end + end + + private + + def owner_or_admin? + record.player == user || user&.admin? + end + end +end +``` + +```ruby +# app/policies/break_escape/scenario_policy.rb +module BreakEscape + class ScenarioPolicy < ApplicationPolicy + def index? + true + end + + def show? + record.published? || user&.admin? + end + + class Scope < Scope + def resolve + if user&.admin? + scope.all + else + scope.published + end + end + end + end +end +``` + +```ruby +# app/policies/break_escape/application_policy.rb +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 Pundit::NotDefinedError, "Cannot resolve #{@scope.name}" + end + + private + + attr_reader :user, :scope + end + end +end +``` + +**Commit:** + +```bash +git add -A +git commit -m "feat: Add Pundit policies for authorization" +``` + +--- + +## Phase 8: Views (Week 4) + +### 8.1 Create game view + +```erb +<%# app/views/break_escape/games/show.html.erb %> + + + + + + <%= @scenario.display_name %> - BreakEscape + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%# Load game stylesheets %> + <%= stylesheet_link_tag '/break_escape/css/styles.css', nonce: true %> + <%= stylesheet_link_tag '/break_escape/css/game.css', nonce: true if File.exist?(Rails.root.join('public/break_escape/css/game.css')) %> + + + <%# Game container %> +
+ + <%# Bootstrap configuration for client %> + + + <%# Load main game JS (ES6 module) %> + <%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: true %> + + +``` + +### 8.2 Create scenarios index view + +```erb +<%# app/views/break_escape/scenarios/index.html.erb %> + + + + + + Select Scenario - BreakEscape + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + +
+

BreakEscape - Select Scenario

+ + <% @scenarios.each do |scenario| %> +
+

<%= scenario.display_name %>

+

<%= scenario.description %>

+

Difficulty: <%= '★' * scenario.difficulty_level %>

+
+ <% end %> +
+ + +``` + +**Commit:** + +```bash +git add -A +git commit -m "feat: Add views for game and scenario selection" +``` + +--- + +## Phase 9: Client Integration (Week 4-5) + +### 9.1 Create API client module + +Create new files in `public/break_escape/js/`: + +```javascript +// public/break_escape/js/config.js +export const CONFIG = { + API_BASE: window.breakEscapeConfig?.apiBasePath || '', + ASSETS_PATH: window.breakEscapeConfig?.assetsPath || 'assets', + GAME_ID: window.breakEscapeConfig?.gameId, + CSRF_TOKEN: window.breakEscapeConfig?.csrfToken, + SCENARIO_NAME: window.breakEscapeConfig?.scenarioName || 'BreakEscape' +}; +``` + +```javascript +// public/break_escape/js/core/api-client.js +import { CONFIG } from '../config.js'; + +class ApiClient { + constructor() { + this.baseUrl = CONFIG.API_BASE; + this.csrfToken = CONFIG.CSRF_TOKEN; + } + + async get(endpoint) { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + async post(endpoint, data) { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || `API Error: ${response.status}`); + } + + return response.json(); + } + + async put(endpoint, data) { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(`API Error: ${response.status}`); + } + + return response.json(); + } + + // Game-specific methods + async bootstrap() { + return this.get('/bootstrap'); + } + + async loadRoom(roomId) { + return this.get(`/rooms/${roomId}`); + } + + async validateUnlock(targetType, targetId, attempt, method) { + return this.post('/unlock', { + targetType, + targetId, + attempt, + method + }); + } + + async updateInventory(action, item) { + return this.post('/inventory', { + action, + item + }); + } + + async loadNpcScript(npcId) { + return this.get(`/npcs/${npcId}/script`); + } + + async syncState(stateUpdates) { + return this.put('/sync_state', stateUpdates); + } +} + +export const apiClient = new ApiClient(); +``` + +### 9.2 Update game initialization + +```javascript +// public/break_escape/js/core/game.js (MODIFY EXISTING) + +import { apiClient } from './api-client.js'; +import { CONFIG } from '../config.js'; + +// Add at top of file +let serverGameState = null; + +// Modify preload function +export function preload() { + console.log('Preloading game assets...'); + + // Load Tiled maps (unchanged) + // ... existing code ... + + // NEW: Bootstrap from server instead of loading local JSON + // (This will be called async, so we need to handle it differently) +} + +// Modify create function +export async function create() { + console.log('Creating game...'); + + // NEW: Load game state from server + try { + serverGameState = await apiClient.bootstrap(); + console.log('Loaded game state from server:', serverGameState); + + // Set window.gameScenario to maintain compatibility + window.gameScenario = { + startRoom: serverGameState.startRoom, + scenarioName: serverGameState.scenarioName, + rooms: {} // Will be populated on-demand + }; + + // Initialize player state + window.playerState = serverGameState.playerState; + + } catch (error) { + console.error('Failed to load game state:', error); + // Fallback or show error + return; + } + + // ... rest of create function unchanged ... +} + +// Add periodic state sync +let lastSyncTime = Date.now(); +const SYNC_INTERVAL = 5000; // 5 seconds + +export function update(time, delta) { + // ... existing update code ... + + // Sync state periodically + if (Date.now() - lastSyncTime > SYNC_INTERVAL) { + syncStateToServer(); + lastSyncTime = Date.now(); + } +} + +async function syncStateToServer() { + if (!window.player) return; + + try { + await apiClient.syncState({ + currentRoom: window.currentRoomId, + position: { + x: window.player.x, + y: window.player.y + }, + globalVariables: window.gameState?.globalVariables || {} + }); + } catch (error) { + console.warn('Failed to sync state:', error); + } +} +``` + +### 9.3 Update room loading + +```javascript +// public/break_escape/js/core/rooms.js (MODIFY EXISTING) + +import { apiClient } from './api-client.js'; + +// Modify loadRoom function to be async +export async function loadRoom(roomId) { + console.log(`Loading room: ${roomId}`); + + // Check if already loaded + if (window.rooms && window.rooms[roomId]) { + console.log(`Room ${roomId} already loaded`); + return; + } + + const position = window.roomPositions[roomId]; + if (!position) { + console.error(`Cannot load room ${roomId}: missing position`); + return; + } + + try { + // NEW: Fetch room data from server + const roomData = await apiClient.loadRoom(roomId); + + console.log(`Received room data for ${roomId}:`, roomData); + + // Store in window.gameScenario for compatibility + if (!window.gameScenario.rooms) { + window.gameScenario.rooms = {}; + } + window.gameScenario.rooms[roomId] = roomData; + + // Create room (existing function, unchanged) + createRoom(roomId, roomData, position); + revealRoom(roomId); + + } catch (error) { + console.error(`Failed to load room ${roomId}:`, error); + + // Show error to player + if (window.showNotification) { + window.showNotification(`Failed to load room: ${error.message}`, 'error'); + } + } +} +``` + +### 9.4 Update unlock system + +```javascript +// public/break_escape/js/systems/unlock-system.js (MODIFY EXISTING) + +import { apiClient } from '../core/api-client.js'; + +// Modify handleUnlock to validate with server +export async function handleUnlock(lockable, type) { + console.log('UNLOCK ATTEMPT'); + playUISound('lock'); + + // Get user attempt (show UI, run minigame, etc.) + const attempt = await getUserUnlockAttempt(lockable); + if (!attempt) return; // User cancelled + + try { + // NEW: Validate with server + const result = await apiClient.validateUnlock( + type, // 'door' or 'object' + lockable.doorProperties?.connectedRoom || lockable.objectId, + attempt.value, + attempt.method // 'key', 'pin', 'password', 'lockpick' + ); + + if (result.success) { + // Unlock locally + unlockTarget(lockable, type, lockable.layer); + + // If door, load room + if (type === 'door' && result.roomData) { + const roomId = lockable.doorProperties.connectedRoom; + const position = window.roomPositions[roomId]; + + // Store room data + window.gameScenario.rooms[roomId] = result.roomData; + + // Create room + createRoom(roomId, result.roomData, position); + revealRoom(roomId); + } + + // If container, show contents + if (type === 'container' && result.contents) { + showContainerContents(lockable, result.contents); + } + + window.gameAlert('Unlocked!', 'success', 'Success', 2000); + } else { + window.gameAlert(result.message || 'Invalid attempt', 'error', 'Failed', 3000); + } + + } catch (error) { + console.error('Unlock validation failed:', error); + window.gameAlert('Server error. Please try again.', 'error', 'Error', 3000); + } +} + +// Helper to get user attempt (combines existing minigame logic) +async function getUserUnlockAttempt(lockable) { + const lockRequirements = getLockRequirements(lockable); + + switch(lockRequirements.lockType) { + case 'key': + // Show key selection or lockpicking + return await getKeyOrLockpickAttempt(lockable); + + case 'pin': + // Show PIN pad + return await getPinAttempt(lockable); + + case 'password': + // Show password input + return await getPasswordAttempt(lockable); + + default: + return null; + } +} + +// ... implement helper functions using existing minigame code ... +``` + +### 9.5 Update NPC loading + +```javascript +// public/break_escape/js/systems/npc-lazy-loader.js (NEW FILE or MODIFY EXISTING) + +import { apiClient } from '../core/api-client.js'; + +export async function loadNPCScript(npcId) { + // Check if already loaded + if (window.npcScripts && window.npcScripts[npcId]) { + return window.npcScripts[npcId]; + } + + try { + const npcData = await apiClient.loadNpcScript(npcId); + + // Cache locally + window.npcScripts = window.npcScripts || {}; + window.npcScripts[npcId] = npcData; + + // Register with NPCManager (if not already registered) + if (window.npcManager && !window.npcManager.getNPC(npcId)) { + window.npcManager.registerNPC({ + id: npcId, + displayName: npcData.displayName || npcId, + storyJSON: npcData.inkScript, + eventMappings: npcData.eventMappings, + timedMessages: npcData.timedMessages, + // ... other NPC properties + }); + } + + return npcData; + + } catch (error) { + console.error(`Failed to load NPC script for ${npcId}:`, error); + throw error; + } +} +``` + +**Commit client changes:** + +```bash +git add -A +git commit -m "feat: Integrate client with server API" +``` + +--- + +## Phase 10: Standalone Mode (Week 5) + +### 10.1 Create DemoUser model + +```bash +rails generate model DemoUser handle:string role:string --skip-migration +``` + +**Create migration manually:** + +```bash +rails generate migration CreateBreakEscapeDemoUsers +``` + +```ruby +# db/migrate/xxx_create_break_escape_demo_users.rb +class CreateBreakEscapeDemoUsers < ActiveRecord::Migration[7.0] + def change + create_table :break_escape_demo_users do |t| + t.string :handle, null: false + t.string :role, default: 'user' + + t.timestamps + end + + add_index :break_escape_demo_users, :handle, unique: true + end +end +``` + +```ruby +# app/models/break_escape/demo_user.rb +module BreakEscape + class DemoUser < ApplicationRecord + self.table_name = 'break_escape_demo_users' + + has_many :game_instances, as: :player, class_name: 'BreakEscape::GameInstance' + + validates :handle, presence: true, uniqueness: true + validates :role, inclusion: { in: %w[admin pro user] } + + def admin? + role == 'admin' + end + + def pro? + role == 'pro' + end + end +end +``` + +**Run migration:** + +```bash +rails db:migrate +``` + +### 10.2 Create standalone config + +```yaml +# config/break_escape_standalone.yml +development: + standalone_mode: true + demo_user: + handle: "demo_player" + role: "pro" + scenarios: + enabled: ['ceo_exfil', 'cybok_heist', 'biometric_breach'] + +test: + standalone_mode: true + demo_user: + handle: "test_player" + role: "user" + +production: + standalone_mode: false # Mounted in Hacktivity +``` + +### 10.3 Update initializer + +```ruby +# config/initializers/break_escape.rb +module BreakEscape + class << self + attr_accessor :configuration + end + + def self.configure + self.configuration ||= Configuration.new + yield(configuration) if block_given? + end + + class Configuration + attr_accessor :standalone_mode, :demo_user, :user_class + + def initialize + load_config + end + + def load_config + config_path = Rails.root.join('config/break_escape_standalone.yml') + return unless File.exist?(config_path) + + config = YAML.load_file(config_path)[Rails.env] || {} + + @standalone_mode = config['standalone_mode'] || false + @demo_user = config['demo_user'] || {} + @user_class = @standalone_mode ? 'BreakEscape::DemoUser' : 'User' + end + end +end + +# Initialize +BreakEscape.configure +``` + +**Commit:** + +```bash +git add -A +git commit -m "feat: Add standalone mode with DemoUser" +``` + +--- + +## Phase 11: Testing (Week 6) + +### 11.1 Create fixtures + +```yaml +# test/fixtures/break_escape/scenarios.yml +ceo_exfil: + name: ceo_exfil + display_name: CEO Exfiltration + description: Break into the CEO's office + published: true + difficulty_level: 3 + scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/ceo_exfil_scenario.json')) %> + +cybok_heist: + name: cybok_heist + display_name: CyBOK Heist + description: Educational cybersecurity scenario + published: true + difficulty_level: 2 + scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/cybok_heist_scenario.json')) %> +``` + +```yaml +# test/fixtures/break_escape/demo_users.yml +demo_player: + handle: demo_player + role: pro + +test_player: + handle: test_player + role: user + +admin_player: + handle: admin_player + role: admin +``` + +```yaml +# test/fixtures/break_escape/game_instances.yml +demo_game: + player: demo_player (DemoUser) + scenario: ceo_exfil + status: in_progress + player_state: + currentRoom: room_reception + unlockedRooms: [room_reception] + inventory: [] + globalVariables: {} +``` + +### 11.2 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(:ceo_exfil) + @demo_user = break_escape_demo_users(:demo_player) + end + + test "can start new game" do + get scenario_path(@scenario) + assert_response :success + + # Should create game instance + game = GameInstance.find_by(player: @demo_user, scenario: @scenario) + assert_not_nil game + end + + test "can load game view" do + game = GameInstance.create!( + player: @demo_user, + scenario: @scenario + ) + + get game_path(game) + assert_response :success + assert_select 'div#break-escape-game' + end + + test "can bootstrap game via API" do + game = GameInstance.create!( + player: @demo_user, + scenario: @scenario + ) + + get bootstrap_api_game_path(game) + assert_response :success + + json = JSON.parse(response.body) + assert_equal game.id, json['gameId'] + assert_equal @scenario.start_room, json['startRoom'] + end + + test "can validate unlock" do + game = GameInstance.create!( + player: @demo_user, + scenario: @scenario + ) + + # Attempt unlock (this will depend on scenario data) + post unlock_api_game_path(game), params: { + targetType: 'door', + targetId: 'room_office', + method: 'password', + attempt: 'correct_password' # Match scenario + } + + assert_response :success + json = JSON.parse(response.body) + assert json['success'] + end + end +end +``` + +```ruby +# test/models/break_escape/game_instance_test.rb +require 'test_helper' + +module BreakEscape + class GameInstanceTest < ActiveSupport::TestCase + test "initializes with start room unlocked" do + scenario = break_escape_scenarios(:ceo_exfil) + game = GameInstance.create!( + player: break_escape_demo_users(:demo_player), + scenario: scenario + ) + + assert game.room_unlocked?(scenario.start_room) + end + + test "can unlock additional rooms" do + game = break_escape_game_instances(:demo_game) + + game.unlock_room!('room_office') + + assert game.room_unlocked?('room_office') + assert_includes game.player_state['unlockedRooms'], 'room_office' + end + + test "can manage inventory" do + game = break_escape_game_instances(:demo_game) + + item = { 'type' => 'key', 'name' => 'Office Key', 'key_id' => 'office_1' } + game.add_inventory_item!(item) + + assert_equal 1, game.player_state['inventory'].length + assert_equal 'Office Key', game.player_state['inventory'].first['name'] + end + end +end +``` + +**Run tests:** + +```bash +rails test +``` + +**Commit:** + +```bash +git add -A +git commit -m "test: Add integration and model tests" +``` + +--- + +## Phase 12: Deployment & Documentation (Week 6) + +### 12.1 Create README + +```markdown +# BreakEscape Rails Engine + +Educational escape room cybersecurity training game as a Rails Engine. + +## Installation + +Add to Gemfile: + +\`\`\`ruby +gem 'break_escape', path: 'path/to/break_escape' +\`\`\` + +Mount in routes: + +\`\`\`ruby +mount BreakEscape::Engine => "/break_escape" +\`\`\` + +Run migrations: + +\`\`\`bash +rails break_escape:install:migrations +rails db:migrate +\`\`\` + +Import scenarios: + +\`\`\`bash +rails db:seed +\`\`\` + +## Standalone Mode + +Configure in `config/break_escape_standalone.yml`: + +\`\`\`yaml +development: + standalone_mode: true + demo_user: + handle: "demo_player" + role: "pro" +\`\`\` + +## Testing + +\`\`\`bash +rails test +\`\`\` + +## License + +MIT +``` + +### 12.2 Final verification checklist + +```bash +# Verify structure +ls -la app/ +ls -la lib/ +ls -la public/break_escape/ +ls -la app/assets/scenarios/ + +# Verify database +rails db:migrate:status + +# Verify seeds +rails console +> BreakEscape::Scenario.count +> BreakEscape::NpcScript.count + +# Run tests +rails test + +# Start server +rails server + +# Visit http://localhost:3000/break_escape +``` + +**Final commit:** + +```bash +git add -A +git commit -m "docs: Add README and final documentation" +``` + +--- + +## Summary + +**All phases complete!** + +You now have a fully functional Rails Engine that: +- ✅ Runs standalone or mounts in Hacktivity +- ✅ Uses JSON storage for game state +- ✅ Validates unlocks server-side +- ✅ Loads NPCs on-demand +- ✅ Minimal client-side changes +- ✅ Well-tested with fixtures +- ✅ Pundit authorization +- ✅ Session-based auth + +**Next steps:** Mount in Hacktivity and test integration! diff --git a/planning_notes/rails-engine-migration-json/03_DATABASE_SCHEMA.md b/planning_notes/rails-engine-migration-json/03_DATABASE_SCHEMA.md new file mode 100644 index 0000000..256224c --- /dev/null +++ b/planning_notes/rails-engine-migration-json/03_DATABASE_SCHEMA.md @@ -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 diff --git a/planning_notes/rails-engine-migration-json/04_TESTING_GUIDE.md b/planning_notes/rails-engine-migration-json/04_TESTING_GUIDE.md new file mode 100644 index 0000000..c10cf88 --- /dev/null +++ b/planning_notes/rails-engine-migration-json/04_TESTING_GUIDE.md @@ -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 diff --git a/planning_notes/rails-engine-migration-json/README.md b/planning_notes/rails-engine-migration-json/README.md new file mode 100644 index 0000000..7f40bb2 --- /dev/null +++ b/planning_notes/rails-engine-migration-json/README.md @@ -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 + +# Or revert specific files +git checkout HEAD -- +``` + +--- + +## 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.**