feat: Add database schema and models

- Create break_escape_missions table (metadata only)
- Create break_escape_games table (state + scenario snapshot)
- Add Mission model with ERB scenario generation
- Add Game model with state management methods
- Use JSONB for flexible state storage
- Polymorphic player association (User/DemoUser)
This commit is contained in:
Z. Cliffe Schreuders
2025-11-21 15:27:53 +00:00
parent d2ef5ff6aa
commit 447cde6356
4 changed files with 293 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
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

View File

@@ -0,0 +1,47 @@
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
validates :difficulty_level, inclusion: { in: 1..5 }
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

View File

@@ -0,0 +1,16 @@
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

View File

@@ -0,0 +1,45 @@
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 (ERB-generated)
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