mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
docs: Add simplified 2-table schema (missions + games)
Added comprehensive planning docs: - 00_OVERVIEW.md: Project aims, philosophy, all decisions - 01_ARCHITECTURE.md: Complete technical design - 02_DATABASE_SCHEMA.md: Full schema reference with examples Key simplifications: - 2 tables instead of 3-4 - Files on filesystem, metadata in database - JIT Ink compilation - Per-instance scenario generation via ERB - Polymorphic player (User/DemoUser) - Session-based auth - Minimal client changes (<5%) Next: Implementation plan with step-by-step TODO list
This commit is contained in:
416
planning_notes/rails-engine-migration-simplified/00_OVERVIEW.md
Normal file
416
planning_notes/rails-engine-migration-simplified/00_OVERVIEW.md
Normal file
@@ -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.
|
||||
@@ -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 %>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
<%= stylesheet_link_tag '/break_escape/css/styles.css' %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="break-escape-game"></div>
|
||||
|
||||
<script nonce="<%= content_security_policy_nonce %>">
|
||||
window.breakEscapeConfig = {
|
||||
gameId: <%= @game.id %>,
|
||||
apiBasePath: '<%= break_escape_game_path(@game) %>',
|
||||
csrfToken: '<%= form_authenticity_token %>'
|
||||
};
|
||||
</script>
|
||||
|
||||
<%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: content_security_policy_nonce %>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user