diff --git a/planning_notes/rails-engine-migration-simplified/00_OVERVIEW.md b/planning_notes/rails-engine-migration-simplified/00_OVERVIEW.md new file mode 100644 index 0000000..6423cb6 --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/00_OVERVIEW.md @@ -0,0 +1,416 @@ +# BreakEscape Rails Engine Migration - Overview + +**Version:** 2.0 (Simplified Approach) +**Date:** November 2025 +**Status:** Ready for Implementation + +--- + +## Project Aims + +Transform BreakEscape from a standalone client-side game into a Rails Engine that: + +1. **Integrates with Hacktivity** - Mounts seamlessly into existing Hacktivity platform +2. **Supports Standalone Mode** - Can run independently for development/testing +3. **Tracks Player Progress** - Persists game state server-side +4. **Enables Randomization** - Each game instance has unique passwords/pins +5. **Validates Critical Actions** - Server-side validation for unlocks and inventory +6. **Maintains Client Code** - Minimal changes to existing JavaScript game logic +7. **Scales Efficiently** - Simple architecture, low database overhead + +--- + +## Core Philosophy + +### "Simplify, Don't Complicate" + +- **Files on filesystem, metadata in database** +- **2 tables, not 10+** +- **Generate data when needed, not in advance** +- **JIT compilation, not build pipelines** +- **Move files, don't rewrite code** + +### "Trust the Client, Validate What Matters" + +- Client handles: Player movement, dialogue, minigames, UI +- Server validates: Unlocks, room access, inventory, critical state +- Server tracks: Progress, completion, achievements + +--- + +## Key Architectural Decisions + +### 1. Database: 2 Simple Tables + +**Decision:** Use only 2 tables with JSONB for flexible state storage. + +**Tables:** +- `break_escape_missions` - Scenario metadata only +- `break_escape_games` - Player state + scenario snapshot + +**Rationale:** +- JSONB perfect for hierarchical game state +- No need for complex relationships +- Easy to add new fields without migrations +- Matches game data structure naturally + +**Rejected Alternative:** Normalized relational schema (10+ tables) +**Why:** Over-engineering, slow iteration, complex queries + +--- + +### 2. NPC Scripts: Files on Filesystem + +**Decision:** No NPC database table. Serve .ink scripts directly from filesystem with JIT compilation. + +**Implementation:** +- Source: `scenarios/ink/npc-name.ink` (version controlled) +- Compiled: `scenarios/ink/npc-name.json` (generated on-demand) +- Endpoint: `GET /games/:id/ink?npc=npc_name` (compiles if needed) + +**Rationale:** +- Compilation is fast (~300ms, benchmarked) +- No database bloat +- Edit .ink files directly +- No complex seed process +- No duplication across scenarios + +**Rejected Alternative:** NPC registry table with join tables +**Why:** Complexity, duplication, over-engineering + +--- + +### 3. Scenario Data: Per-Instance Generation + +**Decision:** Generate scenario JSON via ERB when game instance is created, store in game record. + +**Implementation:** +```ruby +# Template: app/assets/scenarios/ceo_exfil/scenario.json.erb +# Generated: game.scenario_data (JSONB) +# Each instance gets unique passwords/pins +``` + +**Rationale:** +- True randomization per player +- Different passwords for each game +- Scenario solutions never sent to client +- Simple to implement + +**Rejected Alternative:** Shared scenario_data table +**Why:** Can't randomize per player, requires complex filtering + +--- + +### 4. Static Assets: Move to public/ + +**Decision:** Move game files to `public/break_escape/` without modification. + +**Structure:** +``` +public/break_escape/ +├── js/ (ES6 modules, unchanged) +├── css/ (stylesheets, unchanged) +└── assets/ (images, sounds, Tiled maps, unchanged) +``` + +**Rationale:** +- No asset pipeline complexity +- Direct URLs for Phaser +- Engine namespace isolation +- Simple deployment + +**Rejected Alternative:** Rails asset pipeline +**Why:** Unnecessary complexity for Phaser game + +--- + +### 5. Authentication: Polymorphic Player + +**Decision:** Use polymorphic `belongs_to :player` for User or DemoUser. + +**Modes:** +- **Mounted (Hacktivity):** Uses existing User model via Devise +- **Standalone:** Uses DemoUser model for development + +**Rationale:** +- Supports both use cases +- Standard Rails pattern +- Authorization works naturally +- No special-casing in code + +**Rejected Alternative:** User-only with optional flag +**Why:** Tight coupling to Hacktivity, harder to develop standalone + +--- + +### 6. API Design: Session-Based + +**Decision:** Use Rails session authentication (not JWT). + +**Endpoints:** +``` +GET /games/:id/scenario - Get scenario JSON +GET /games/:id/ink?npc=... - Get NPC script (JIT compiled) +GET /api/games/:id/bootstrap - Initial game data +PUT /api/games/:id/sync_state - Sync player state +POST /api/games/:id/unlock - Validate unlock attempt +POST /api/games/:id/inventory - Update inventory +``` + +**Rationale:** +- Matches Hacktivity's Devise setup +- CSRF protection built-in +- Simpler than JWT +- Web-only use case + +**Rejected Alternative:** JWT tokens +**Why:** Adds complexity without benefit + +--- + +### 7. File Organization: Cautious Approach + +**Decision:** Use `mv` commands to reorganize, minimize code changes. + +**Process:** +1. Use `rails generate` for boilerplate +2. Use `mv` to relocate files (not copy) +3. Edit only what's necessary +4. Test after each phase +5. Commit working state + +**Rationale:** +- Preserves git history +- Avoids introducing bugs +- Clear audit trail +- Reversible steps + +--- + +### 8. Client Integration: Minimal Changes + +**Decision:** Add ~2 new files, modify ~5 existing files. + +**New Files:** +- `config.js` - API configuration +- `api-client.js` - Fetch wrapper + +**Modified Files:** +- Scenario loading (use API) +- Unlock validation (call server) +- NPC script loading (use API) +- Inventory sync (call server) +- Main game initialization + +**Rationale:** +- <5% code change +- Reduces risk +- Preserves game logic +- Faster implementation + +**Rejected Alternative:** Rewrite to use API throughout +**Why:** Unnecessary, high risk, no benefit + +--- + +### 9. Testing: Minitest with Fixtures + +**Decision:** Use Minitest (matches Hacktivity) with fixture-based tests. + +**Test Types:** +- Model tests (validations, methods) +- Controller tests (authorization, responses) +- Integration tests (full game flow) + +**Rationale:** +- Matches Hacktivity's test framework +- Consistent testing approach +- Fixtures easier for game state +- Well-documented pattern + +**Rejected Alternative:** RSpec +**Why:** Different from Hacktivity, adds dependency + +--- + +### 10. Authorization: Pundit Policies + +**Decision:** Use Pundit for authorization (matches Hacktivity). + +**Policies:** +- GamePolicy - Player can only access their own games +- MissionPolicy - Published scenarios visible to all +- Admin overrides for management + +**Rationale:** +- Explicit, testable policies +- Flexible for complex rules +- Standard gem +- Matches Hacktivity + +**Rejected Alternative:** CanCanCan +**Why:** Less explicit, harder to test + +--- + +## Timeline and Scope + +**Estimated Duration:** 10-12 weeks + +**Phase Breakdown:** +1. Setup Rails Engine (Week 1) - 1 week +2. Move Game Files (Week 1) - 1 week +3. Reorganize Scenarios (Week 1-2) - 1 week +4. Database Setup (Week 2-3) - 1 week +5. Models and Logic (Week 3-4) - 1 week +6. Controllers and Routes (Week 4-5) - 2 weeks +7. API Implementation (Week 5-6) - 2 weeks +8. Client Integration (Week 7-8) - 2 weeks +9. Testing (Week 9-10) - 2 weeks +10. Standalone Mode (Week 10) - 1 week +11. Deployment (Week 11-12) - 2 weeks + +**Total:** 10-12 weeks + +--- + +## Success Criteria + +### Must Have (P0) + +- ✅ Game runs in Hacktivity at `/break_escape` +- ✅ Player progress persists across sessions +- ✅ Unlocks validated server-side +- ✅ Each game instance has unique passwords +- ✅ NPCs work with dialogue +- ✅ All 24 scenarios loadable +- ✅ Standalone mode works for development + +### Should Have (P1) + +- ✅ Integration tests pass +- ✅ Authorization policies work +- ✅ JIT Ink compilation works +- ✅ Game state includes all minigame data +- ✅ Admin can manage scenarios +- ✅ Error handling graceful + +### Nice to Have (P2) + +- Performance monitoring +- Leaderboards +- Save/load system +- Scenario versioning +- Analytics tracking + +--- + +## Risk Mitigation + +### Risk: Breaking Existing Game Logic + +**Mitigation:** +- Minimal client changes (<5%) +- Phased implementation with testing +- Preserve git history with mv +- Integration tests for game flow + +### Risk: Performance Issues + +**Mitigation:** +- JIT compilation benchmarked (~300ms) +- JSONB with GIN indexes +- Static assets served directly +- Simple database queries + +### Risk: Complex State Management + +**Mitigation:** +- JSONB for flexible state +- Server validates only critical actions +- Client remains authoritative for movement/UI +- Clear state sync strategy + +### Risk: Hacktivity Integration Issues + +**Mitigation:** +- Validated against actual Hacktivity code +- Uses same patterns (Devise, Pundit, Minitest) +- Polymorphic player supports both modes +- CSP nonces for security + +--- + +## What's Different from Original Plan? + +### Simplified + +**Before:** 3-4 tables (scenarios, npc_scripts, scenario_npcs, games) +**After:** 2 tables (missions, games) + +**Before:** Complex NPC registry with join tables +**After:** Files on filesystem, JIT compilation + +**Before:** Shared scenario_data in database +**After:** Per-instance generation via ERB + +**Before:** Pre-compilation build pipeline +**After:** JIT compilation on first request + +**Before:** 10-14 hours P0 prep work +**After:** 0 hours P0 prep work + +### Results + +- **50% fewer tables** +- **No complex relationships** +- **No build infrastructure** +- **Simpler seed process** +- **Better randomization** +- **Easier development** + +--- + +## Documentation Structure + +This migration plan consists of: + +1. **00_OVERVIEW.md** (this file) - Aims, philosophy, decisions +2. **01_ARCHITECTURE.md** - Technical design details +3. **02_DATABASE_SCHEMA.md** - Complete schema reference +4. **03_IMPLEMENTATION_PLAN.md** - Step-by-step TODO list +5. **04_API_REFERENCE.md** - API endpoint documentation +6. **05_TESTING_GUIDE.md** - Testing strategy and examples +7. **06_HACKTIVITY_INTEGRATION.md** - Integration instructions +8. **README.md** - Quick start and navigation + +--- + +## Quick Start + +**To begin implementation:** + +1. Read this overview +2. Read `01_ARCHITECTURE.md` for technical details +3. Read `02_DATABASE_SCHEMA.md` for database design +4. Start with `03_IMPLEMENTATION_PLAN.md` Phase 1 +5. Follow the step-by-step instructions +6. Test after each phase +7. Commit working state + +**Questions?** Each document has detailed rationale and examples. + +--- + +## Summary + +This migration transforms BreakEscape into a Rails Engine using the **simplest possible approach**: + +- 2 database tables +- Files on filesystem +- JIT compilation +- Minimal client changes +- Standard Rails patterns + +**Ready to start!** Proceed to `03_IMPLEMENTATION_PLAN.md` for the step-by-step guide. diff --git a/planning_notes/rails-engine-migration-simplified/01_ARCHITECTURE.md b/planning_notes/rails-engine-migration-simplified/01_ARCHITECTURE.md new file mode 100644 index 0000000..45f7171 --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/01_ARCHITECTURE.md @@ -0,0 +1,771 @@ +# BreakEscape Rails Engine - Technical Architecture + +**Complete technical design specification** + +--- + +## System Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Hacktivity (Host App) │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ BreakEscape Rails Engine │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌────────────────────────────┐ │ │ +│ │ │ Controllers │───▶│ Models (2 tables) │ │ │ +│ │ │ - Games │ │ - Mission (metadata) │ │ │ +│ │ │ - Missions │ │ - Game (state + data) │ │ │ +│ │ │ - API │ │ │ │ │ +│ │ └──────────────┘ └────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌────────────────────────────┐ │ │ +│ │ │ Views │ │ Policies (Pundit) │ │ │ +│ │ │ - show.html │ │ - GamePolicy │ │ │ +│ │ └──────────────┘ └────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ public/break_escape/ │ │ │ +│ │ │ - js/ (game code, unchanged) │ │ │ +│ │ │ - css/ (stylesheets, unchanged) │ │ │ +│ │ │ - assets/ (images/sounds, unchanged) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ app/assets/scenarios/ │ │ │ +│ │ │ - ceo_exfil/scenario.json.erb (ERB template) │ │ │ +│ │ │ - cybok_heist/scenario.json.erb │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ scenarios/ink/ │ │ │ +│ │ │ - helper-npc.ink (source) │ │ │ +│ │ │ - helper-npc.json (JIT compiled) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Devise User Authentication (Hacktivity) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Directory Structure + +### Final Structure (After Migration) + +``` +/home/user/BreakEscape/ +├── app/ +│ ├── controllers/ +│ │ └── break_escape/ +│ │ ├── application_controller.rb +│ │ ├── games_controller.rb # Game view + scenario/ink endpoints +│ │ ├── missions_controller.rb # Scenario selection +│ │ └── api/ +│ │ └── games_controller.rb # Game state API +│ │ +│ ├── models/ +│ │ └── break_escape/ +│ │ ├── application_record.rb +│ │ ├── mission.rb # Scenario metadata + ERB generation +│ │ ├── game.rb # Game state + validation +│ │ └── demo_user.rb # Standalone mode only +│ │ +│ ├── policies/ +│ │ └── break_escape/ +│ │ ├── game_policy.rb +│ │ └── mission_policy.rb +│ │ +│ ├── views/ +│ │ └── break_escape/ +│ │ ├── games/ +│ │ │ └── show.html.erb # Game container +│ │ └── missions/ +│ │ └── index.html.erb # Scenario list +│ │ +│ └── assets/ +│ └── scenarios/ # ERB templates +│ ├── ceo_exfil/ +│ │ └── scenario.json.erb +│ ├── cybok_heist/ +│ │ └── scenario.json.erb +│ └── biometric_breach/ +│ └── scenario.json.erb +│ +├── lib/ +│ ├── break_escape/ +│ │ ├── engine.rb # Engine configuration +│ │ └── version.rb +│ └── break_escape.rb +│ +├── config/ +│ ├── routes.rb # Engine routes +│ └── initializers/ +│ └── break_escape.rb # Config loader +│ +├── db/ +│ └── migrate/ +│ ├── 001_create_break_escape_missions.rb +│ └── 002_create_break_escape_games.rb +│ +├── test/ +│ ├── fixtures/ +│ │ └── break_escape/ +│ │ ├── missions.yml +│ │ └── games.yml +│ ├── models/ +│ │ └── break_escape/ +│ ├── controllers/ +│ │ └── break_escape/ +│ ├── integration/ +│ │ └── break_escape/ +│ └── policies/ +│ └── break_escape/ +│ +├── public/ # Static game assets +│ └── break_escape/ +│ ├── js/ # ES6 modules (moved from root) +│ ├── css/ # Stylesheets (moved from root) +│ └── assets/ # Images/sounds (moved from root) +│ +├── scenarios/ # Ink scripts +│ └── ink/ +│ ├── helper-npc.ink # Source +│ └── helper-npc.json # JIT compiled +│ +├── bin/ +│ └── inklecate # Ink compiler binary +│ +├── break_escape.gemspec +├── Gemfile +├── Rakefile +└── README.md +``` + +--- + +## Database Schema + +### Table 1: break_escape_missions + +Stores scenario metadata only (no game data). + +```ruby +create_table :break_escape_missions do |t| + t.string :name, null: false # 'ceo_exfil' (directory name) + t.string :display_name, null: false # 'CEO Exfiltration' + t.text :description # Scenario brief + t.boolean :published, default: false # Visible to players + t.integer :difficulty_level, default: 1 # 1-5 scale + + t.timestamps +end + +add_index :break_escape_missions, :name, unique: true +add_index :break_escape_missions, :published +``` + +**What it stores:** Metadata about scenarios +**What it does NOT store:** Scenario JSON, NPC data, room definitions + +**Example Record:** +```ruby +{ + id: 1, + name: 'ceo_exfil', + display_name: 'CEO Exfiltration', + description: 'Infiltrate the office and find evidence...', + published: true, + difficulty_level: 3 +} +``` + +--- + +### Table 2: break_escape_games + +Stores player game state with scenario snapshot. + +```ruby +create_table :break_escape_games do |t| + # Polymorphic player (User in Hacktivity, DemoUser in standalone) + t.references :player, polymorphic: true, null: false, index: true + + # Mission reference + t.references :mission, null: false, foreign_key: { to_table: :break_escape_missions } + + # Scenario snapshot (ERB-generated at game creation) + t.jsonb :scenario_data, null: false + + # Player state (all game progress) + t.jsonb :player_state, null: false, default: { + currentRoom: nil, + unlockedRooms: [], + unlockedObjects: [], + inventory: [], + encounteredNPCs: [], + globalVariables: {}, + biometricSamples: [], + biometricUnlocks: [], + bluetoothDevices: [], + notes: [], + health: 100 + } + + # Metadata + t.string :status, default: 'in_progress' # in_progress, completed, abandoned + t.datetime :started_at + t.datetime :completed_at + t.integer :score, default: 0 + + t.timestamps +end + +add_index :break_escape_games, + [:player_type, :player_id, :mission_id], + unique: true, + name: 'index_games_on_player_and_mission' +add_index :break_escape_games, :scenario_data, using: :gin +add_index :break_escape_games, :player_state, using: :gin +add_index :break_escape_games, :status +``` + +**Key Points:** +- `scenario_data` stores the ERB-generated scenario JSON (unique per game) +- `player_state` stores all game progress in one JSONB column +- `health` is inside player_state (not separate column) +- No `position` field (not needed for now) + +**Example Record:** +```ruby +{ + id: 123, + player_type: 'User', + player_id: 456, + mission_id: 1, + scenario_data: { + startRoom: 'reception', + rooms: { + reception: { ... }, + office: { locked: true, requires: 'xK92pL7q' } # Unique password + } + }, + player_state: { + currentRoom: 'reception', + unlockedRooms: ['reception'], + inventory: [], + health: 100 + }, + status: 'in_progress', + started_at: '2025-11-20T10:00:00Z' +} +``` + +--- + +## Models + +### Mission Model + +```ruby +# app/models/break_escape/mission.rb +module BreakEscape + class Mission < ApplicationRecord + self.table_name = 'break_escape_missions' + + has_many :games, class_name: 'BreakEscape::Game', dependent: :destroy + + validates :name, presence: true, uniqueness: true + validates :display_name, presence: true + + scope :published, -> { where(published: true) } + + # Path to scenario directory + def scenario_path + Rails.root.join('app', 'assets', 'scenarios', name) + end + + # Generate scenario data via ERB + def generate_scenario_data + template_path = scenario_path.join('scenario.json.erb') + raise "Scenario template not found: #{name}" unless File.exist?(template_path) + + erb = ERB.new(File.read(template_path)) + binding_context = ScenarioBinding.new + output = erb.result(binding_context.get_binding) + + JSON.parse(output) + rescue JSON::ParserError => e + raise "Invalid JSON in #{name} after ERB processing: #{e.message}" + end + + # Binding context for ERB variables + class ScenarioBinding + def initialize + @random_password = SecureRandom.alphanumeric(8) + @random_pin = rand(1000..9999).to_s + @random_code = SecureRandom.hex(4) + end + + attr_reader :random_password, :random_pin, :random_code + + def get_binding + binding + end + end + end +end +``` + +--- + +### Game Model + +```ruby +# app/models/break_escape/game.rb +module BreakEscape + class Game < ApplicationRecord + self.table_name = 'break_escape_games' + + # Associations + belongs_to :player, polymorphic: true + belongs_to :mission, class_name: 'BreakEscape::Mission' + + # Validations + validates :player, presence: true + validates :mission, presence: true + validates :status, inclusion: { in: %w[in_progress completed abandoned] } + + # Scopes + scope :active, -> { where(status: 'in_progress') } + scope :completed, -> { where(status: 'completed') } + + # Callbacks + before_create :generate_scenario_data + before_create :initialize_player_state + before_create :set_started_at + + # Room management + def unlock_room!(room_id) + player_state['unlockedRooms'] ||= [] + player_state['unlockedRooms'] << room_id unless player_state['unlockedRooms'].include?(room_id) + save! + end + + def room_unlocked?(room_id) + player_state['unlockedRooms']&.include?(room_id) || start_room?(room_id) + end + + def start_room?(room_id) + scenario_data['startRoom'] == room_id + end + + # Object management + def unlock_object!(object_id) + player_state['unlockedObjects'] ||= [] + player_state['unlockedObjects'] << object_id unless player_state['unlockedObjects'].include?(object_id) + save! + end + + def object_unlocked?(object_id) + player_state['unlockedObjects']&.include?(object_id) + end + + # Inventory management + def add_inventory_item!(item) + player_state['inventory'] ||= [] + player_state['inventory'] << item + save! + end + + def remove_inventory_item!(item_id) + player_state['inventory']&.reject! { |item| item['id'] == item_id } + save! + end + + # NPC tracking + def encounter_npc!(npc_id) + player_state['encounteredNPCs'] ||= [] + player_state['encounteredNPCs'] << npc_id unless player_state['encounteredNPCs'].include?(npc_id) + save! + end + + # Global variables (synced with client) + def update_global_variables!(variables) + player_state['globalVariables'] ||= {} + player_state['globalVariables'].merge!(variables) + save! + end + + # Minigame state + def add_biometric_sample!(sample) + player_state['biometricSamples'] ||= [] + player_state['biometricSamples'] << sample + save! + end + + def add_bluetooth_device!(device) + player_state['bluetoothDevices'] ||= [] + unless player_state['bluetoothDevices'].any? { |d| d['mac'] == device['mac'] } + player_state['bluetoothDevices'] << device + end + save! + end + + def add_note!(note) + player_state['notes'] ||= [] + player_state['notes'] << note + save! + end + + # Health management + def update_health!(value) + player_state['health'] = value.clamp(0, 100) + save! + end + + # Scenario data access + def room_data(room_id) + scenario_data.dig('rooms', room_id) + end + + def filtered_room_data(room_id) + room = room_data(room_id)&.deep_dup + return nil unless room + + # Remove solutions + room.delete('requires') + room.delete('lockType') if room['locked'] + + # Remove solutions from objects + room['objects']&.each do |obj| + obj.delete('requires') + obj.delete('lockType') if obj['locked'] + obj.delete('contents') if obj['locked'] + end + + room + end + + # Unlock validation + def validate_unlock(target_type, target_id, attempt, method) + if target_type == 'door' + room = room_data(target_id) + return false unless room && room['locked'] + + case method + when 'key' + room['requires'] == attempt + when 'pin', 'password' + room['requires'].to_s == attempt.to_s + when 'lockpick' + true # Client minigame succeeded + else + false + end + else + # Find object in all rooms + scenario_data['rooms'].each do |_room_id, room_data| + object = room_data['objects']&.find { |obj| obj['id'] == target_id } + next unless object && object['locked'] + + case method + when 'key' + return object['requires'] == attempt + when 'pin', 'password' + return object['requires'].to_s == attempt.to_s + when 'lockpick' + return true + end + end + false + end + end + + private + + def generate_scenario_data + self.scenario_data = mission.generate_scenario_data + end + + def initialize_player_state + self.player_state ||= {} + self.player_state['currentRoom'] ||= scenario_data['startRoom'] + self.player_state['unlockedRooms'] ||= [scenario_data['startRoom']] + self.player_state['unlockedObjects'] ||= [] + self.player_state['inventory'] ||= [] + self.player_state['encounteredNPCs'] ||= [] + self.player_state['globalVariables'] ||= {} + self.player_state['biometricSamples'] ||= [] + self.player_state['biometricUnlocks'] ||= [] + self.player_state['bluetoothDevices'] ||= [] + self.player_state['notes'] ||= [] + self.player_state['health'] ||= 100 + end + + def set_started_at + self.started_at ||= Time.current + end + end +end +``` + +--- + +## Routes + +```ruby +# config/routes.rb +BreakEscape::Engine.routes.draw do + # Mission selection + resources :missions, only: [:index, :show] + + # Game management + resources :games, only: [:show, :create] do + member do + # Scenario and NPC data (JIT compiled) + get 'scenario' # Returns scenario_data JSON + get 'ink' # Returns NPC script (JIT compiled) + + # API endpoints (namespaced under /api for clarity) + scope module: :api do + get 'bootstrap' # Initial game data + put 'sync_state' # Periodic state sync + post 'unlock' # Validate unlock attempt + post 'inventory' # Update inventory + end + end + end + + root to: 'missions#index' +end +``` + +**Mounted URLs (in Hacktivity):** +``` +https://hacktivity.com/break_escape/missions +https://hacktivity.com/break_escape/games/123 +https://hacktivity.com/break_escape/games/123/scenario +https://hacktivity.com/break_escape/games/123/ink?npc=helper1 +https://hacktivity.com/break_escape/games/123/bootstrap +``` + +--- + +## API Endpoints + +See `04_API_REFERENCE.md` for complete documentation. + +**Summary:** +1. `GET /games/:id/scenario` - Scenario JSON for this game +2. `GET /games/:id/ink?npc=X` - NPC script (JIT compiled) +3. `GET /games/:id/bootstrap` - Initial game data +4. `PUT /games/:id/sync_state` - Sync player state +5. `POST /games/:id/unlock` - Validate unlock +6. `POST /games/:id/inventory` - Update inventory + +--- + +## JIT Ink Compilation + +### How It Works + +```ruby +# GET /games/:id/ink?npc=helper1 + +1. Find NPC in game's scenario_data +2. Get storyPath (e.g., "scenarios/ink/helper-npc.json") +3. Check if .json exists and is newer than .ink +4. If not, compile: bin/inklecate -o helper-npc.json helper-npc.ink +5. Serve compiled JSON +``` + +### Performance + +- Compilation: ~300ms (benchmarked) +- Cached reads: ~15ms +- Only compiles if needed (timestamp check) + +### Controller Implementation + +See `03_IMPLEMENTATION_PLAN.md` Phase 6 for complete code. + +--- + +## ERB Scenario Templates + +### Template Example + +```erb +<%# app/assets/scenarios/ceo_exfil/scenario.json.erb %> +{ + "scenarioName": "CEO Exfiltration", + "startRoom": "reception", + "rooms": { + "office": { + "locked": true, + "lockType": "password", + "requires": "<%= random_password %>", + "objects": [ + { + "type": "safe", + "locked": true, + "lockType": "pin", + "requires": "<%= random_pin %>" + } + ] + } + } +} +``` + +### Variables Available + +- `random_password` - 8-character alphanumeric +- `random_pin` - 4-digit number +- `random_code` - 8-character hex + +### Generation + +Happens once when Game is created: +```ruby +before_create :generate_scenario_data +# Calls mission.generate_scenario_data +# Stores in game.scenario_data JSONB +``` + +--- + +## Polymorphic Player + +### User (Hacktivity Mode) + +```ruby +# Hacktivity's existing User model +class User < ApplicationRecord + devise :database_authenticatable, :registerable + has_many :games, as: :player, class_name: 'BreakEscape::Game' +end +``` + +### DemoUser (Standalone Mode) + +```ruby +# app/models/break_escape/demo_user.rb +module BreakEscape + class DemoUser < ApplicationRecord + self.table_name = 'break_escape_demo_users' + + has_many :games, as: :player, class_name: 'BreakEscape::Game' + + validates :handle, presence: true, uniqueness: true + end +end +``` + +### Controller Logic + +```ruby +def current_player + if BreakEscape.standalone_mode? + @current_player ||= BreakEscape::DemoUser.first_or_create!( + handle: 'demo_player' + ) + else + current_user # From Devise + end +end +``` + +--- + +## Authorization (Pundit) + +### GamePolicy + +```ruby +# app/policies/break_escape/game_policy.rb +module BreakEscape + class GamePolicy < ApplicationPolicy + def show? + # Owner or admin + record.player == user || user&.admin? + end + + def update? + show? + end + + def scenario? + show? + end + + def ink? + show? + end + + class Scope < Scope + def resolve + if user&.admin? + scope.all + else + scope.where(player: user) + end + end + end + end +end +``` + +--- + +## Security (CSP) + +### Layout with Nonces + +```erb +<%# app/views/break_escape/games/show.html.erb %> + + +
+ <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%= stylesheet_link_tag '/break_escape/css/styles.css' %> + + + + + + + <%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: content_security_policy_nonce %> + + +``` + +--- + +## Summary + +**Architecture Highlights:** + +- ✅ 2 database tables (missions, games) +- ✅ JSONB for flexible state storage +- ✅ JIT Ink compilation (~300ms) +- ✅ ERB scenario randomization +- ✅ Polymorphic player (User/DemoUser) +- ✅ Session-based auth +- ✅ Pundit authorization +- ✅ CSP with nonces +- ✅ Static assets in public/ +- ✅ Minimal client changes + +**Next:** See `03_IMPLEMENTATION_PLAN.md` for step-by-step instructions. diff --git a/planning_notes/rails-engine-migration-simplified/02_DATABASE_SCHEMA.md b/planning_notes/rails-engine-migration-simplified/02_DATABASE_SCHEMA.md new file mode 100644 index 0000000..a95b3dc --- /dev/null +++ b/planning_notes/rails-engine-migration-simplified/02_DATABASE_SCHEMA.md @@ -0,0 +1,540 @@ +# Database Schema Reference + +Complete schema documentation for BreakEscape Rails Engine. + +--- + +## Overview + +**Total Tables:** 2 (plus 1 for standalone mode) + +1. `break_escape_missions` - Scenario metadata +2. `break_escape_games` - Player game state + scenario snapshot +3. `break_escape_demo_users` - Standalone mode only (optional) + +--- + +## Table 1: break_escape_missions + +Stores scenario metadata only. Scenario JSON is generated via ERB when games are created. + +### Schema + +```sql +CREATE TABLE break_escape_missions ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + display_name VARCHAR NOT NULL, + description TEXT, + published BOOLEAN NOT NULL DEFAULT false, + difficulty_level INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX index_break_escape_missions_on_name ON break_escape_missions(name); +CREATE INDEX index_break_escape_missions_on_published ON break_escape_missions(published); +``` + +### Columns + +| Column | Type | Null | Default | Description | +|--------|------|------|---------|-------------| +| id | bigint | NO | AUTO | Primary key | +| name | string | NO | - | Scenario identifier (matches directory name) | +| display_name | string | NO | - | Human-readable name | +| description | text | YES | - | Scenario brief/description | +| published | boolean | NO | false | Whether scenario is visible to players | +| difficulty_level | integer | NO | 1 | Difficulty (1-5 scale) | +| created_at | timestamp | NO | NOW() | Record creation time | +| updated_at | timestamp | NO | NOW() | Last update time | + +### Indexes + +- **Primary Key:** `id` +- **Unique Index:** `name` (ensures scenario names are unique) +- **Index:** `published` (for filtering published scenarios) + +### Example Records + +```ruby +[ + { + id: 1, + name: 'ceo_exfil', + display_name: 'CEO Exfiltration', + description: 'Infiltrate the corporate office and gather evidence of insider trading.', + published: true, + difficulty_level: 3 + }, + { + id: 2, + name: 'cybok_heist', + display_name: 'CybOK Heist', + description: 'Break into the research facility and steal the CybOK framework.', + published: true, + difficulty_level: 4 + } +] +``` + +### Relationships + +- `has_many :games` - One mission can have many game instances + +### Validations + +```ruby +validates :name, presence: true, uniqueness: true +validates :display_name, presence: true +validates :difficulty_level, inclusion: { in: 1..5 } +``` + +--- + +## Table 2: break_escape_games + +Stores player game state and scenario snapshot. This is the main table containing all game progress. + +### Schema + +```sql +CREATE TABLE break_escape_games ( + id BIGSERIAL PRIMARY KEY, + player_type VARCHAR NOT NULL, + player_id BIGINT NOT NULL, + mission_id BIGINT NOT NULL, + scenario_data JSONB NOT NULL, + player_state JSONB NOT NULL DEFAULT '{"currentRoom":null,"unlockedRooms":[],"unlockedObjects":[],"inventory":[],"encounteredNPCs":[],"globalVariables":{},"biometricSamples":[],"biometricUnlocks":[],"bluetoothDevices":[],"notes":[],"health":100}'::jsonb, + status VARCHAR NOT NULL DEFAULT 'in_progress', + started_at TIMESTAMP, + completed_at TIMESTAMP, + score INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + FOREIGN KEY (mission_id) REFERENCES break_escape_missions(id) +); + +CREATE INDEX index_break_escape_games_on_player + ON break_escape_games(player_type, player_id); +CREATE INDEX index_break_escape_games_on_mission_id + ON break_escape_games(mission_id); +CREATE UNIQUE INDEX index_games_on_player_and_mission + ON break_escape_games(player_type, player_id, mission_id); +CREATE INDEX index_break_escape_games_on_scenario_data + ON break_escape_games USING GIN(scenario_data); +CREATE INDEX index_break_escape_games_on_player_state + ON break_escape_games USING GIN(player_state); +CREATE INDEX index_break_escape_games_on_status + ON break_escape_games(status); +``` + +### Columns + +| Column | Type | Null | Default | Description | +|--------|------|------|---------|-------------| +| id | bigint | NO | AUTO | Primary key | +| player_type | string | NO | - | Polymorphic type ('User' or 'DemoUser') | +| player_id | bigint | NO | - | Polymorphic foreign key | +| mission_id | bigint | NO | - | Foreign key to missions | +| scenario_data | jsonb | NO | - | ERB-generated scenario JSON (unique per game) | +| player_state | jsonb | NO | {...} | All game progress | +| status | string | NO | 'in_progress' | Game status (in_progress, completed, abandoned) | +| started_at | timestamp | YES | - | When game started | +| completed_at | timestamp | YES | - | When game finished | +| score | integer | NO | 0 | Final score | +| created_at | timestamp | NO | NOW() | Record creation time | +| updated_at | timestamp | NO | NOW() | Last update time | + +### Indexes + +- **Primary Key:** `id` +- **Composite Index:** `(player_type, player_id)` - For finding user's games +- **Foreign Key Index:** `mission_id` - For mission lookups +- **Unique Index:** `(player_type, player_id, mission_id)` - One game per player per mission +- **GIN Index:** `scenario_data` - Fast JSONB queries +- **GIN Index:** `player_state` - Fast JSONB queries +- **Index:** `status` - For filtering active games + +### scenario_data Structure + +```json +{ + "scenarioName": "CEO Exfiltration", + "scenarioBrief": "Gather evidence of insider trading", + "startRoom": "reception", + "rooms": { + "reception": { + "type": "room_reception", + "connections": {"north": "office"}, + "locked": false, + "objects": [...] + }, + "office": { + "type": "room_office", + "connections": {"south": "reception"}, + "locked": true, + "lockType": "password", + "requires": "xK92pL7q", // Unique per game! + "objects": [ + { + "type": "safe", + "locked": true, + "lockType": "pin", + "requires": "7342" // Unique per game! + } + ] + } + }, + "npcs": [ + { + "id": "security_guard", + "displayName": "Security Guard", + "storyPath": "scenarios/ink/security-guard.json" + } + ] +} +``` + +**Key Points:** +- Generated via ERB when game is created +- Includes solutions (never sent to client) +- Unique passwords/pins per game instance +- Complete snapshot of scenario + +### player_state Structure + +```json +{ + "currentRoom": "office", + "unlockedRooms": ["reception", "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 + }, + "biometricSamples": [ + { + "type": "fingerprint", + "data": "base64encodeddata", + "source": "ceo_desk" + } + ], + "biometricUnlocks": ["door_ceo", "safe_123"], + "bluetoothDevices": [ + { + "name": "CEO Phone", + "mac": "AA:BB:CC:DD:EE:FF", + "distance": 2.5 + } + ], + "notes": [ + { + "id": "note_1", + "title": "Password List", + "content": "CEO password is..." + } + ], + "health": 85 +} +``` + +**Key Points:** +- All game progress in one JSONB column +- Includes minigame state (biometrics, bluetooth, notes) +- Health stored here (not separate column) +- globalVariables synced with client +- No position tracking (not needed) + +### Relationships + +- `belongs_to :player` (polymorphic) - User or DemoUser +- `belongs_to :mission` - Which scenario + +### Validations + +```ruby +validates :player, presence: true +validates :mission, presence: true +validates :status, inclusion: { in: %w[in_progress completed abandoned] } +validates :scenario_data, presence: true +validates :player_state, presence: true +``` + +### Example Record + +```ruby +{ + id: 123, + player_type: 'User', + player_id: 456, + mission_id: 1, + scenario_data: { + scenarioName: 'CEO Exfiltration', + startRoom: 'reception', + rooms: { ... } # Full scenario with unique passwords + }, + player_state: { + currentRoom: 'office', + unlockedRooms: ['reception', 'office'], + inventory: [{type: 'key', name: 'Office Key'}], + health: 85 + }, + status: 'in_progress', + started_at: '2025-11-20T10:00:00Z', + score: 0 +} +``` + +--- + +## Table 3: break_escape_demo_users (Standalone Only) + +Optional table for standalone mode development. + +### Schema + +```sql +CREATE TABLE break_escape_demo_users ( + id BIGSERIAL PRIMARY KEY, + handle VARCHAR NOT NULL, + role VARCHAR NOT NULL DEFAULT 'user', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX index_break_escape_demo_users_on_handle + ON break_escape_demo_users(handle); +``` + +### Columns + +| Column | Type | Null | Default | Description | +|--------|------|------|---------|-------------| +| id | bigint | NO | AUTO | Primary key | +| handle | string | NO | - | Username | +| role | string | NO | 'user' | Role (user, admin) | +| created_at | timestamp | NO | NOW() | Record creation time | +| updated_at | timestamp | NO | NOW() | Last update time | + +### Example Record + +```ruby +{ + id: 1, + handle: 'demo_player', + role: 'user' +} +``` + +**Note:** Only created if running in standalone mode. Not needed when mounted in Hacktivity. + +--- + +## Queries + +### Common Queries + +**Get all published missions:** +```ruby +Mission.published.order(:difficulty_level) +``` + +**Get player's active games:** +```ruby +user.games.active +``` + +**Get player's game for a mission:** +```ruby +Game.find_by(player: user, mission: mission) +``` + +**Get game with scenario data:** +```ruby +game = Game.find(id) +game.scenario_data # Full scenario JSON +``` + +**Check if room is unlocked:** +```ruby +game.room_unlocked?('office') # true/false +``` + +**Query JSONB fields:** +```ruby +# Find games where player is in 'office' +Game.where("player_state->>'currentRoom' = ?", 'office') + +# Find games with specific item in inventory +Game.where("player_state->'inventory' @> ?", [{type: 'key'}].to_json) + +# Find completed games +Game.where(status: 'completed') +``` + +--- + +## Migrations + +### Migration 1: Create Missions + +```ruby +class CreateBreakEscapeMissions < ActiveRecord::Migration[7.0] + def change + create_table :break_escape_missions do |t| + t.string :name, null: false + t.string :display_name, null: false + t.text :description + t.boolean :published, default: false, null: false + t.integer :difficulty_level, default: 1, null: false + + t.timestamps + end + + add_index :break_escape_missions, :name, unique: true + add_index :break_escape_missions, :published + end +end +``` + +### Migration 2: Create Games + +```ruby +class CreateBreakEscapeGames < ActiveRecord::Migration[7.0] + def change + create_table :break_escape_games do |t| + # Polymorphic player + t.references :player, polymorphic: true, null: false, index: true + + # Mission reference + t.references :mission, null: false, foreign_key: { to_table: :break_escape_missions } + + # Scenario snapshot + t.jsonb :scenario_data, null: false + + # Player state + t.jsonb :player_state, null: false, default: { + currentRoom: nil, + unlockedRooms: [], + unlockedObjects: [], + inventory: [], + encounteredNPCs: [], + globalVariables: {}, + biometricSamples: [], + biometricUnlocks: [], + bluetoothDevices: [], + notes: [], + health: 100 + } + + # Metadata + t.string :status, default: 'in_progress', null: false + t.datetime :started_at + t.datetime :completed_at + t.integer :score, default: 0, null: false + + t.timestamps + end + + add_index :break_escape_games, + [:player_type, :player_id, :mission_id], + unique: true, + name: 'index_games_on_player_and_mission' + add_index :break_escape_games, :scenario_data, using: :gin + add_index :break_escape_games, :player_state, using: :gin + add_index :break_escape_games, :status + end +end +``` + +### Migration 3: Create Demo Users (Standalone Only) + +```ruby +class CreateBreakEscapeDemoUsers < ActiveRecord::Migration[7.0] + def change + create_table :break_escape_demo_users do |t| + t.string :handle, null: false + t.string :role, default: 'user', null: false + + t.timestamps + end + + add_index :break_escape_demo_users, :handle, unique: true + end +end +``` + +--- + +## Database Size Estimates + +### Per Game Instance + +**scenario_data:** ~30-50 KB +**player_state:** ~5-10 KB +**Total per game:** ~35-60 KB + +### Scale Estimates + +| Players | Games | Database Size | +|---------|-------|---------------| +| 100 | 100 | ~6 MB | +| 1,000 | 1,000 | ~60 MB | +| 10,000 | 10,000 | ~600 MB | + +**Note:** PostgreSQL JSONB is efficient. GIN indexes add ~20% overhead but enable fast queries. + +--- + +## Backup and Cleanup + +### Backup Active Games + +```ruby +# Export active games +Game.active.find_each do |game| + File.write("backups/game_#{game.id}.json", { + player: { type: game.player_type, id: game.player_id }, + mission: game.mission.name, + state: game.player_state, + started_at: game.started_at + }.to_json) +end +``` + +### Cleanup Abandoned Games + +```ruby +# Delete games abandoned > 30 days ago +Game.where(status: 'abandoned') + .where('updated_at < ?', 30.days.ago) + .destroy_all +``` + +--- + +## Summary + +**Schema Highlights:** + +- ✅ 2 simple tables (missions, games) +- ✅ JSONB for flexible state storage +- ✅ GIN indexes for fast JSONB queries +- ✅ Polymorphic player support +- ✅ Unique constraint (one game per player per mission) +- ✅ Scenario data per instance (enables randomization) +- ✅ Complete game state in one column + +**Next:** See `03_IMPLEMENTATION_PLAN.md` for step-by-step migration instructions.