mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
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:
185
app/models/break_escape/game.rb
Normal file
185
app/models/break_escape/game.rb
Normal 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
|
||||
47
app/models/break_escape/mission.rb
Normal file
47
app/models/break_escape/mission.rb
Normal 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
|
||||
16
db/migrate/20251120155357_create_break_escape_missions.rb
Normal file
16
db/migrate/20251120155357_create_break_escape_missions.rb
Normal 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
|
||||
45
db/migrate/20251120155358_create_break_escape_games.rb
Normal file
45
db/migrate/20251120155358_create_break_escape_games.rb
Normal 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
|
||||
Reference in New Issue
Block a user