From 447cde63567f7a85ace3451e7d2e3c59a328b80e Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 21 Nov 2025 15:27:53 +0000 Subject: [PATCH] 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) --- app/models/break_escape/game.rb | 185 ++++++++++++++++++ app/models/break_escape/mission.rb | 47 +++++ ...1120155357_create_break_escape_missions.rb | 16 ++ ...0251120155358_create_break_escape_games.rb | 45 +++++ 4 files changed, 293 insertions(+) create mode 100644 app/models/break_escape/game.rb create mode 100644 app/models/break_escape/mission.rb create mode 100644 db/migrate/20251120155357_create_break_escape_missions.rb create mode 100644 db/migrate/20251120155358_create_break_escape_games.rb diff --git a/app/models/break_escape/game.rb b/app/models/break_escape/game.rb new file mode 100644 index 0000000..52c9640 --- /dev/null +++ b/app/models/break_escape/game.rb @@ -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 diff --git a/app/models/break_escape/mission.rb b/app/models/break_escape/mission.rb new file mode 100644 index 0000000..5cd4ab0 --- /dev/null +++ b/app/models/break_escape/mission.rb @@ -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 diff --git a/db/migrate/20251120155357_create_break_escape_missions.rb b/db/migrate/20251120155357_create_break_escape_missions.rb new file mode 100644 index 0000000..f725513 --- /dev/null +++ b/db/migrate/20251120155357_create_break_escape_missions.rb @@ -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 diff --git a/db/migrate/20251120155358_create_break_escape_games.rb b/db/migrate/20251120155358_create_break_escape_games.rb new file mode 100644 index 0000000..890cc21 --- /dev/null +++ b/db/migrate/20251120155358_create_break_escape_games.rb @@ -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