mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
Comprehensive implementation plan for converting BreakEscape to a Rails Engine. DOCUMENTATION CREATED: - 00_OVERVIEW.md: Project aims, philosophy, decisions summary - 01_ARCHITECTURE.md: Technical design, models, controllers, API - 02_IMPLEMENTATION_PLAN.md: Phases 1-6 with bash/rails commands - 02_IMPLEMENTATION_PLAN_PART2.md: Phases 7-12 with client integration - 03_DATABASE_SCHEMA.md: 3-table JSONB schema reference - 04_TESTING_GUIDE.md: Fixtures, tests, CI setup - README.md: Quick start and navigation guide KEY APPROACH: - Simplified JSON-centric storage (3 tables vs 10+) - JSONB for player state (one column, all game data) - Minimal client changes (move files, add API client) - Dual mode: Standalone + Hacktivity integration - Session-based auth with polymorphic player - Pundit policies for authorization - ERB templates for scenario randomization TIMELINE: 12-14 weeks (vs 22 weeks complex approach) ARCHITECTURE DECISIONS: - Static assets in public/break_escape/ - Scenarios in app/assets/scenarios/ with ERB - .ink and .ink.json files organized by scenario - Lazy-load NPC scripts on encounter - Server validates unlocks, client runs dialogue - 6 API endpoints (not 15+) Each phase includes: - Specific bash mv commands - Rails generate and migrate commands - Code examples with manual edits - Testing steps - Git commit points Ready for implementation.
29 KiB
29 KiB
BreakEscape Rails Engine - Implementation Plan (Part 2)
Continued from 02_IMPLEMENTATION_PLAN.md
Phase 6 (Continued): API Controllers (Week 3)
6.2 API Games Controller
# app/controllers/break_escape/api/games_controller.rb
module BreakEscape
module Api
class GamesController < ApplicationController
before_action :set_game_instance
# GET /api/games/:id/bootstrap
def bootstrap
authorize @game_instance if defined?(Pundit)
render json: {
gameId: @game_instance.id,
scenarioName: @game_instance.scenario.display_name,
startRoom: @game_instance.scenario.start_room,
playerState: @game_instance.player_state,
roomLayout: build_room_layout
}
end
# PUT /api/games/:id/sync_state
def sync_state
authorize @game_instance if defined?(Pundit)
# Update player state (partial update)
@game_instance.player_state.merge!(sync_params)
@game_instance.save!
render json: { success: true }
end
# POST /api/games/:id/unlock
def unlock
authorize @game_instance if defined?(Pundit)
target_type = params[:targetType] # 'door' or 'object'
target_id = params[:targetId]
attempt = params[:attempt]
method = params[:method]
# Validate with scenario
is_valid = @game_instance.scenario.validate_unlock(
target_type,
target_id,
attempt,
method
)
if is_valid
if target_type == 'door'
@game_instance.unlock_room!(target_id)
room_data = @game_instance.scenario.filtered_room_data(target_id)
render json: {
success: true,
type: 'door',
roomData: room_data
}
else
@game_instance.unlock_object!(target_id)
# Get object contents from scenario
contents = find_object_contents(target_id)
render json: {
success: true,
type: 'object',
contents: contents
}
end
else
render json: {
success: false,
message: 'Invalid attempt'
}, status: :unprocessable_entity
end
end
# POST /api/games/:id/inventory
def inventory
authorize @game_instance if defined?(Pundit)
action = params[:action] # 'add' or 'remove'
item = params[:item]
case action
when 'add'
# Validate item exists in unlocked location
if validate_item_accessible(item)
@game_instance.add_inventory_item!(item)
render json: { success: true, inventory: @game_instance.player_state['inventory'] }
else
render json: { success: false, message: 'Item not accessible' }, status: :forbidden
end
when 'remove'
@game_instance.remove_inventory_item!(item['id'])
render json: { success: true, inventory: @game_instance.player_state['inventory'] }
else
render json: { success: false, message: 'Invalid action' }, status: :bad_request
end
end
private
def set_game_instance
@game_instance = GameInstance.find(params[:id])
end
def sync_params
params.permit(:currentRoom, position: [:x, :y], globalVariables: {})
end
def build_room_layout
# Return all room connections but no lock details
layout = {}
@game_instance.scenario.scenario_data['rooms'].each do |room_id, room_data|
layout[room_id] = {
connections: room_data['connections'],
locked: room_data['locked'] || false
# Deliberately exclude lockType and requires
}
end
layout
end
def find_object_contents(object_id)
# Search all rooms for this object
@game_instance.scenario.scenario_data['rooms'].each do |_room_id, room_data|
object = room_data['objects']&.find { |obj| obj['id'] == object_id }
return object['contents'] if object
end
[]
end
def validate_item_accessible(item)
# Check if item is in an unlocked room/object
# Simplified: trust client for now, add validation later if needed
true
end
end
end
end
6.3 API Rooms Controller
# app/controllers/break_escape/api/rooms_controller.rb
module BreakEscape
module Api
class RoomsController < ApplicationController
before_action :set_game_instance
before_action :set_room
# GET /api/games/:game_id/rooms/:id
def show
authorize @game_instance if defined?(Pundit)
# Check if room is unlocked
unless @game_instance.room_unlocked?(params[:id])
render json: { error: 'Room not unlocked' }, status: :forbidden
return
end
render json: @game_instance.scenario.filtered_room_data(params[:id])
end
private
def set_game_instance
@game_instance = GameInstance.find(params[:game_id])
end
def set_room
@room_id = params[:id]
end
end
end
end
6.4 API NPCs Controller
# app/controllers/break_escape/api/npcs_controller.rb
module BreakEscape
module Api
class NpcsController < ApplicationController
before_action :set_game_instance
before_action :set_npc
# GET /api/games/:game_id/npcs/:id/script
def script
authorize @game_instance if defined?(Pundit)
# Check if player has encountered this NPC
# (Either in current room OR already encountered)
unless can_access_npc?
render json: { error: 'NPC not accessible' }, status: :forbidden
return
end
# Mark as encountered
@game_instance.encounter_npc!(params[:id]) unless @game_instance.npc_encountered?(params[:id])
# Load NPC script
npc_script = @game_instance.scenario.npc_scripts.find_by(npc_id: params[:id])
unless npc_script
render json: { error: 'NPC script not found' }, status: :not_found
return
end
# Get NPC data from scenario
npc_data = @game_instance.scenario.scenario_data['npcs']&.find { |npc| npc['id'] == params[:id] }
render json: {
npcId: params[:id],
inkScript: JSON.parse(npc_script.ink_compiled),
eventMappings: npc_data&.dig('eventMappings') || [],
timedMessages: npc_data&.dig('timedMessages') || []
}
end
private
def set_game_instance
@game_instance = GameInstance.find(params[:game_id])
end
def set_npc
@npc_id = params[:id]
end
def can_access_npc?
# NPC is accessible if already encountered OR in current room
return true if @game_instance.npc_encountered?(@npc_id)
# Check if NPC is in current room
current_room = @game_instance.player_state['currentRoom']
room_data = @game_instance.scenario.scenario_data['rooms'][current_room]
npc_in_room = room_data&.dig('npcs')&.include?(@npc_id)
npc_in_room || false
end
end
end
end
Commit:
git add -A
git commit -m "feat: Add API controllers for game state, rooms, and NPCs"
Phase 7: Policies (Week 3)
7.1 Generate policies
# Create policies directory
mkdir -p app/policies/break_escape
# Generate policy files
rails generate pundit:policy break_escape/game_instance
rails generate pundit:policy break_escape/scenario
Edit policies:
# app/policies/break_escape/game_instance_policy.rb
module BreakEscape
class GameInstancePolicy < ApplicationPolicy
def show?
owner_or_admin?
end
def update?
owner_or_admin?
end
def destroy?
owner_or_admin?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.where(player: user)
end
end
end
private
def owner_or_admin?
record.player == user || user&.admin?
end
end
end
# app/policies/break_escape/scenario_policy.rb
module BreakEscape
class ScenarioPolicy < ApplicationPolicy
def index?
true
end
def show?
record.published? || user&.admin?
end
class Scope < Scope
def resolve
if user&.admin?
scope.all
else
scope.published
end
end
end
end
end
# app/policies/break_escape/application_policy.rb
module BreakEscape
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise Pundit::NotDefinedError, "Cannot resolve #{@scope.name}"
end
private
attr_reader :user, :scope
end
end
end
Commit:
git add -A
git commit -m "feat: Add Pundit policies for authorization"
Phase 8: Views (Week 4)
8.1 Create game view
<%# app/views/break_escape/games/show.html.erb %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= @scenario.display_name %> - BreakEscape</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%# Load game stylesheets %>
<%= stylesheet_link_tag '/break_escape/css/styles.css', nonce: true %>
<%= stylesheet_link_tag '/break_escape/css/game.css', nonce: true if File.exist?(Rails.root.join('public/break_escape/css/game.css')) %>
</head>
<body>
<%# Game container %>
<div id="break-escape-game"></div>
<%# Bootstrap configuration for client %>
<script nonce="<%= content_security_policy_nonce %>">
window.breakEscapeConfig = {
gameId: <%= @game_instance.id %>,
scenarioName: '<%= j @scenario.display_name %>',
apiBasePath: '<%= api_game_path(@game_instance) %>',
assetsPath: '/break_escape/assets',
csrfToken: '<%= form_authenticity_token %>'
};
</script>
<%# Load main game JS (ES6 module) %>
<%= javascript_include_tag '/break_escape/js/main.js', type: 'module', nonce: true %>
</body>
</html>
8.2 Create scenarios index view
<%# app/views/break_escape/scenarios/index.html.erb %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select Scenario - BreakEscape</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<style nonce="<%= content_security_policy_nonce %>">
body {
font-family: 'Courier New', monospace;
background: #1a1a1a;
color: #00ff00;
padding: 20px;
}
.scenarios {
max-width: 800px;
margin: 0 auto;
}
.scenario {
border: 1px solid #00ff00;
padding: 20px;
margin: 20px 0;
cursor: pointer;
transition: background 0.2s;
}
.scenario:hover {
background: #002200;
}
.scenario h2 {
margin: 0 0 10px 0;
}
.difficulty {
color: #ffff00;
}
</style>
</head>
<body>
<div class="scenarios">
<h1>BreakEscape - Select Scenario</h1>
<% @scenarios.each do |scenario| %>
<div class="scenario" onclick="window.location='<%= scenario_path(scenario) %>'">
<h2><%= scenario.display_name %></h2>
<p><%= scenario.description %></p>
<p class="difficulty">Difficulty: <%= '★' * scenario.difficulty_level %></p>
</div>
<% end %>
</div>
</body>
</html>
Commit:
git add -A
git commit -m "feat: Add views for game and scenario selection"
Phase 9: Client Integration (Week 4-5)
9.1 Create API client module
Create new files in public/break_escape/js/:
// public/break_escape/js/config.js
export const CONFIG = {
API_BASE: window.breakEscapeConfig?.apiBasePath || '',
ASSETS_PATH: window.breakEscapeConfig?.assetsPath || 'assets',
GAME_ID: window.breakEscapeConfig?.gameId,
CSRF_TOKEN: window.breakEscapeConfig?.csrfToken,
SCENARIO_NAME: window.breakEscapeConfig?.scenarioName || 'BreakEscape'
};
// public/break_escape/js/core/api-client.js
import { CONFIG } from '../config.js';
class ApiClient {
constructor() {
this.baseUrl = CONFIG.API_BASE;
this.csrfToken = CONFIG.CSRF_TOKEN;
}
async get(endpoint) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async post(endpoint, data) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': this.csrfToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `API Error: ${response.status}`);
}
return response.json();
}
async put(endpoint, data) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': this.csrfToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
// Game-specific methods
async bootstrap() {
return this.get('/bootstrap');
}
async loadRoom(roomId) {
return this.get(`/rooms/${roomId}`);
}
async validateUnlock(targetType, targetId, attempt, method) {
return this.post('/unlock', {
targetType,
targetId,
attempt,
method
});
}
async updateInventory(action, item) {
return this.post('/inventory', {
action,
item
});
}
async loadNpcScript(npcId) {
return this.get(`/npcs/${npcId}/script`);
}
async syncState(stateUpdates) {
return this.put('/sync_state', stateUpdates);
}
}
export const apiClient = new ApiClient();
9.2 Update game initialization
// public/break_escape/js/core/game.js (MODIFY EXISTING)
import { apiClient } from './api-client.js';
import { CONFIG } from '../config.js';
// Add at top of file
let serverGameState = null;
// Modify preload function
export function preload() {
console.log('Preloading game assets...');
// Load Tiled maps (unchanged)
// ... existing code ...
// NEW: Bootstrap from server instead of loading local JSON
// (This will be called async, so we need to handle it differently)
}
// Modify create function
export async function create() {
console.log('Creating game...');
// NEW: Load game state from server
try {
serverGameState = await apiClient.bootstrap();
console.log('Loaded game state from server:', serverGameState);
// Set window.gameScenario to maintain compatibility
window.gameScenario = {
startRoom: serverGameState.startRoom,
scenarioName: serverGameState.scenarioName,
rooms: {} // Will be populated on-demand
};
// Initialize player state
window.playerState = serverGameState.playerState;
} catch (error) {
console.error('Failed to load game state:', error);
// Fallback or show error
return;
}
// ... rest of create function unchanged ...
}
// Add periodic state sync
let lastSyncTime = Date.now();
const SYNC_INTERVAL = 5000; // 5 seconds
export function update(time, delta) {
// ... existing update code ...
// Sync state periodically
if (Date.now() - lastSyncTime > SYNC_INTERVAL) {
syncStateToServer();
lastSyncTime = Date.now();
}
}
async function syncStateToServer() {
if (!window.player) return;
try {
await apiClient.syncState({
currentRoom: window.currentRoomId,
position: {
x: window.player.x,
y: window.player.y
},
globalVariables: window.gameState?.globalVariables || {}
});
} catch (error) {
console.warn('Failed to sync state:', error);
}
}
9.3 Update room loading
// public/break_escape/js/core/rooms.js (MODIFY EXISTING)
import { apiClient } from './api-client.js';
// Modify loadRoom function to be async
export async function loadRoom(roomId) {
console.log(`Loading room: ${roomId}`);
// Check if already loaded
if (window.rooms && window.rooms[roomId]) {
console.log(`Room ${roomId} already loaded`);
return;
}
const position = window.roomPositions[roomId];
if (!position) {
console.error(`Cannot load room ${roomId}: missing position`);
return;
}
try {
// NEW: Fetch room data from server
const roomData = await apiClient.loadRoom(roomId);
console.log(`Received room data for ${roomId}:`, roomData);
// Store in window.gameScenario for compatibility
if (!window.gameScenario.rooms) {
window.gameScenario.rooms = {};
}
window.gameScenario.rooms[roomId] = roomData;
// Create room (existing function, unchanged)
createRoom(roomId, roomData, position);
revealRoom(roomId);
} catch (error) {
console.error(`Failed to load room ${roomId}:`, error);
// Show error to player
if (window.showNotification) {
window.showNotification(`Failed to load room: ${error.message}`, 'error');
}
}
}
9.4 Update unlock system
// public/break_escape/js/systems/unlock-system.js (MODIFY EXISTING)
import { apiClient } from '../core/api-client.js';
// Modify handleUnlock to validate with server
export async function handleUnlock(lockable, type) {
console.log('UNLOCK ATTEMPT');
playUISound('lock');
// Get user attempt (show UI, run minigame, etc.)
const attempt = await getUserUnlockAttempt(lockable);
if (!attempt) return; // User cancelled
try {
// NEW: Validate with server
const result = await apiClient.validateUnlock(
type, // 'door' or 'object'
lockable.doorProperties?.connectedRoom || lockable.objectId,
attempt.value,
attempt.method // 'key', 'pin', 'password', 'lockpick'
);
if (result.success) {
// Unlock locally
unlockTarget(lockable, type, lockable.layer);
// If door, load room
if (type === 'door' && result.roomData) {
const roomId = lockable.doorProperties.connectedRoom;
const position = window.roomPositions[roomId];
// Store room data
window.gameScenario.rooms[roomId] = result.roomData;
// Create room
createRoom(roomId, result.roomData, position);
revealRoom(roomId);
}
// If container, show contents
if (type === 'container' && result.contents) {
showContainerContents(lockable, result.contents);
}
window.gameAlert('Unlocked!', 'success', 'Success', 2000);
} else {
window.gameAlert(result.message || 'Invalid attempt', 'error', 'Failed', 3000);
}
} catch (error) {
console.error('Unlock validation failed:', error);
window.gameAlert('Server error. Please try again.', 'error', 'Error', 3000);
}
}
// Helper to get user attempt (combines existing minigame logic)
async function getUserUnlockAttempt(lockable) {
const lockRequirements = getLockRequirements(lockable);
switch(lockRequirements.lockType) {
case 'key':
// Show key selection or lockpicking
return await getKeyOrLockpickAttempt(lockable);
case 'pin':
// Show PIN pad
return await getPinAttempt(lockable);
case 'password':
// Show password input
return await getPasswordAttempt(lockable);
default:
return null;
}
}
// ... implement helper functions using existing minigame code ...
9.5 Update NPC loading
// public/break_escape/js/systems/npc-lazy-loader.js (NEW FILE or MODIFY EXISTING)
import { apiClient } from '../core/api-client.js';
export async function loadNPCScript(npcId) {
// Check if already loaded
if (window.npcScripts && window.npcScripts[npcId]) {
return window.npcScripts[npcId];
}
try {
const npcData = await apiClient.loadNpcScript(npcId);
// Cache locally
window.npcScripts = window.npcScripts || {};
window.npcScripts[npcId] = npcData;
// Register with NPCManager (if not already registered)
if (window.npcManager && !window.npcManager.getNPC(npcId)) {
window.npcManager.registerNPC({
id: npcId,
displayName: npcData.displayName || npcId,
storyJSON: npcData.inkScript,
eventMappings: npcData.eventMappings,
timedMessages: npcData.timedMessages,
// ... other NPC properties
});
}
return npcData;
} catch (error) {
console.error(`Failed to load NPC script for ${npcId}:`, error);
throw error;
}
}
Commit client changes:
git add -A
git commit -m "feat: Integrate client with server API"
Phase 10: Standalone Mode (Week 5)
10.1 Create DemoUser model
rails generate model DemoUser handle:string role:string --skip-migration
Create migration manually:
rails generate migration CreateBreakEscapeDemoUsers
# db/migrate/xxx_create_break_escape_demo_users.rb
class CreateBreakEscapeDemoUsers < ActiveRecord::Migration[7.0]
def change
create_table :break_escape_demo_users do |t|
t.string :handle, null: false
t.string :role, default: 'user'
t.timestamps
end
add_index :break_escape_demo_users, :handle, unique: true
end
end
# app/models/break_escape/demo_user.rb
module BreakEscape
class DemoUser < ApplicationRecord
self.table_name = 'break_escape_demo_users'
has_many :game_instances, as: :player, class_name: 'BreakEscape::GameInstance'
validates :handle, presence: true, uniqueness: true
validates :role, inclusion: { in: %w[admin pro user] }
def admin?
role == 'admin'
end
def pro?
role == 'pro'
end
end
end
Run migration:
rails db:migrate
10.2 Create standalone config
# config/break_escape_standalone.yml
development:
standalone_mode: true
demo_user:
handle: "demo_player"
role: "pro"
scenarios:
enabled: ['ceo_exfil', 'cybok_heist', 'biometric_breach']
test:
standalone_mode: true
demo_user:
handle: "test_player"
role: "user"
production:
standalone_mode: false # Mounted in Hacktivity
10.3 Update initializer
# config/initializers/break_escape.rb
module BreakEscape
class << self
attr_accessor :configuration
end
def self.configure
self.configuration ||= Configuration.new
yield(configuration) if block_given?
end
class Configuration
attr_accessor :standalone_mode, :demo_user, :user_class
def initialize
load_config
end
def load_config
config_path = Rails.root.join('config/break_escape_standalone.yml')
return unless File.exist?(config_path)
config = YAML.load_file(config_path)[Rails.env] || {}
@standalone_mode = config['standalone_mode'] || false
@demo_user = config['demo_user'] || {}
@user_class = @standalone_mode ? 'BreakEscape::DemoUser' : 'User'
end
end
end
# Initialize
BreakEscape.configure
Commit:
git add -A
git commit -m "feat: Add standalone mode with DemoUser"
Phase 11: Testing (Week 6)
11.1 Create fixtures
# test/fixtures/break_escape/scenarios.yml
ceo_exfil:
name: ceo_exfil
display_name: CEO Exfiltration
description: Break into the CEO's office
published: true
difficulty_level: 3
scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/ceo_exfil_scenario.json')) %>
cybok_heist:
name: cybok_heist
display_name: CyBOK Heist
description: Educational cybersecurity scenario
published: true
difficulty_level: 2
scenario_data: <%= File.read(Rails.root.join('test/fixtures/files/cybok_heist_scenario.json')) %>
# test/fixtures/break_escape/demo_users.yml
demo_player:
handle: demo_player
role: pro
test_player:
handle: test_player
role: user
admin_player:
handle: admin_player
role: admin
# test/fixtures/break_escape/game_instances.yml
demo_game:
player: demo_player (DemoUser)
scenario: ceo_exfil
status: in_progress
player_state:
currentRoom: room_reception
unlockedRooms: [room_reception]
inventory: []
globalVariables: {}
11.2 Integration tests
# test/integration/break_escape/game_flow_test.rb
require 'test_helper'
module BreakEscape
class GameFlowTest < ActionDispatch::IntegrationTest
include Engine.routes.url_helpers
setup do
@scenario = break_escape_scenarios(:ceo_exfil)
@demo_user = break_escape_demo_users(:demo_player)
end
test "can start new game" do
get scenario_path(@scenario)
assert_response :success
# Should create game instance
game = GameInstance.find_by(player: @demo_user, scenario: @scenario)
assert_not_nil game
end
test "can load game view" do
game = GameInstance.create!(
player: @demo_user,
scenario: @scenario
)
get game_path(game)
assert_response :success
assert_select 'div#break-escape-game'
end
test "can bootstrap game via API" do
game = GameInstance.create!(
player: @demo_user,
scenario: @scenario
)
get bootstrap_api_game_path(game)
assert_response :success
json = JSON.parse(response.body)
assert_equal game.id, json['gameId']
assert_equal @scenario.start_room, json['startRoom']
end
test "can validate unlock" do
game = GameInstance.create!(
player: @demo_user,
scenario: @scenario
)
# Attempt unlock (this will depend on scenario data)
post unlock_api_game_path(game), params: {
targetType: 'door',
targetId: 'room_office',
method: 'password',
attempt: 'correct_password' # Match scenario
}
assert_response :success
json = JSON.parse(response.body)
assert json['success']
end
end
end
# test/models/break_escape/game_instance_test.rb
require 'test_helper'
module BreakEscape
class GameInstanceTest < ActiveSupport::TestCase
test "initializes with start room unlocked" do
scenario = break_escape_scenarios(:ceo_exfil)
game = GameInstance.create!(
player: break_escape_demo_users(:demo_player),
scenario: scenario
)
assert game.room_unlocked?(scenario.start_room)
end
test "can unlock additional rooms" do
game = break_escape_game_instances(:demo_game)
game.unlock_room!('room_office')
assert game.room_unlocked?('room_office')
assert_includes game.player_state['unlockedRooms'], 'room_office'
end
test "can manage inventory" do
game = break_escape_game_instances(:demo_game)
item = { 'type' => 'key', 'name' => 'Office Key', 'key_id' => 'office_1' }
game.add_inventory_item!(item)
assert_equal 1, game.player_state['inventory'].length
assert_equal 'Office Key', game.player_state['inventory'].first['name']
end
end
end
Run tests:
rails test
Commit:
git add -A
git commit -m "test: Add integration and model tests"
Phase 12: Deployment & Documentation (Week 6)
12.1 Create README
# BreakEscape Rails Engine
Educational escape room cybersecurity training game as a Rails Engine.
## Installation
Add to Gemfile:
\`\`\`ruby
gem 'break_escape', path: 'path/to/break_escape'
\`\`\`
Mount in routes:
\`\`\`ruby
mount BreakEscape::Engine => "/break_escape"
\`\`\`
Run migrations:
\`\`\`bash
rails break_escape:install:migrations
rails db:migrate
\`\`\`
Import scenarios:
\`\`\`bash
rails db:seed
\`\`\`
## Standalone Mode
Configure in `config/break_escape_standalone.yml`:
\`\`\`yaml
development:
standalone_mode: true
demo_user:
handle: "demo_player"
role: "pro"
\`\`\`
## Testing
\`\`\`bash
rails test
\`\`\`
## License
MIT
12.2 Final verification checklist
# Verify structure
ls -la app/
ls -la lib/
ls -la public/break_escape/
ls -la app/assets/scenarios/
# Verify database
rails db:migrate:status
# Verify seeds
rails console
> BreakEscape::Scenario.count
> BreakEscape::NpcScript.count
# Run tests
rails test
# Start server
rails server
# Visit http://localhost:3000/break_escape
Final commit:
git add -A
git commit -m "docs: Add README and final documentation"
Summary
All phases complete!
You now have a fully functional Rails Engine that:
- ✅ Runs standalone or mounts in Hacktivity
- ✅ Uses JSON storage for game state
- ✅ Validates unlocks server-side
- ✅ Loads NPCs on-demand
- ✅ Minimal client-side changes
- ✅ Well-tested with fixtures
- ✅ Pundit authorization
- ✅ Session-based auth
Next steps: Mount in Hacktivity and test integration!