diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb index 499d247..3bbb7e2 100644 --- a/app/controllers/break_escape/games_controller.rb +++ b/app/controllers/break_escape/games_controller.rb @@ -2,7 +2,69 @@ require 'open3' module BreakEscape class GamesController < ApplicationController - before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory, :objectives, :complete_task, :update_task_progress] + before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory, :objectives, :complete_task, :update_task_progress, :submit_flag] + + # GET /games/new?mission_id=:id + # Show VM set selection page for VM-required missions + def new + @mission = Mission.find(params[:mission_id]) + authorize @mission, :create_game? if defined?(Pundit) + + if @mission.requires_vms? + @available_vm_sets = @mission.valid_vm_sets_for_user(current_user) + @existing_games = Game.where(player: current_player, mission: @mission) + end + end + + # POST /games + # Create a new game instance for a mission + def create + @mission = Mission.find(params[:mission_id]) + authorize @mission, :create_game? if defined?(Pundit) + + # Build initial player_state with VM/flag context + initial_player_state = {} + + # Hacktivity mode with VM set + if params[:vm_set_id].present? && defined?(::VmSet) + vm_set = ::VmSet.find_by(id: params[:vm_set_id]) + return render json: { error: 'VM set not found' }, status: :not_found unless vm_set + + # Validate VM set belongs to user and matches mission + if BreakEscape::Mission.hacktivity_mode? + unless @mission.valid_vm_sets_for_user(current_user).include?(vm_set) + return render json: { error: 'Invalid VM set for this mission' }, status: :forbidden + end + initial_player_state['vm_set_id'] = vm_set.id + else + # Standalone mode - vm_set_id shouldn't be used + Rails.logger.warn "[BreakEscape] vm_set_id provided but not in Hacktivity mode, ignoring" + end + end + + # Standalone mode with manual flags + if params[:standalone_flags].present? + flags = if params[:standalone_flags].is_a?(Array) + params[:standalone_flags] + else + params[:standalone_flags].split(',').map(&:strip).reject(&:blank?) + end + initial_player_state['standalone_flags'] = flags + end + + # CRITICAL: Set player_state BEFORE save! so callbacks can read vm_set_id + # Callback order is: + # 1. before_create :generate_scenario_data_with_context (reads player_state['vm_set_id']) + # 2. before_create :initialize_player_state (adds default fields) + @game = Game.new( + player: current_player, + mission: @mission + ) + @game.player_state = initial_player_state + @game.save! + + redirect_to game_path(@game) + end def show authorize @game if defined?(Pundit) @@ -28,6 +90,11 @@ module BreakEscape filtered['objectivesState'] = @game.player_state['objectivesState'] end + # Include submitted flags for flag station minigame + if @game.player_state['submitted_flags'].present? + filtered['submittedFlags'] = @game.player_state['submitted_flags'] + end + render json: filtered rescue => e Rails.logger.error "[BreakEscape] scenario error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" @@ -375,6 +442,43 @@ module BreakEscape render json: result end + # ========================================== + # VM/Flag Integration + # ========================================== + + # POST /games/:id/flags + # Submit a CTF flag for validation + def submit_flag + authorize @game if defined?(Pundit) + + flag_key = params[:flag] + + unless flag_key.present? + return render json: { success: false, message: 'No flag provided' }, status: :bad_request + end + + result = @game.submit_flag(flag_key) + + if result[:success] + # Find rewards for this flag in scenario + rewards = find_flag_rewards(flag_key) + + # Process rewards + reward_results = process_flag_rewards(flag_key, rewards) + + Rails.logger.info "[BreakEscape] Flag submitted: #{flag_key}, rewards: #{reward_results.length}" + + render json: { + success: true, + message: result[:message], + flag: flag_key, + rewards: reward_results + } + else + render json: result, status: :unprocessable_entity + end + end + private def set_game @@ -776,5 +880,134 @@ module BreakEscape def render_error(message, status) render json: { error: message }, status: status end + + # ========================================== + # Flag Reward Helpers + # ========================================== + + def find_flag_rewards(flag_key) + rewards = [] + + # Search scenario for flag-station with this flag + @game.scenario_data['rooms']&.each do |room_id, room| + room['objects']&.each do |obj| + next unless obj['type'] == 'flag-station' + next unless obj['flags']&.any? { |f| f.downcase == flag_key.downcase } + + flag_station_id = obj['id'] || obj['name'] + + # Support both hash structure (preferred) and array structure (legacy) + if obj['flagRewards'].is_a?(Hash) + # Hash structure: { "flag{key}": { "type": "unlock_door", ... } } + # Case-insensitive lookup + reward_key = obj['flagRewards'].keys.find { |k| k.downcase == flag_key.downcase } + reward = obj['flagRewards'][reward_key] if reward_key + if reward + rewards << reward.merge( + 'flag_station_id' => flag_station_id, + 'room_id' => room_id + ) + end + elsif obj['flagRewards'].is_a?(Array) + # Array structure (legacy): rewards[i] corresponds to flags[i] + flag_index = obj['flags'].find_index { |f| f.downcase == flag_key.downcase } + if flag_index && obj['flagRewards'][flag_index] + rewards << obj['flagRewards'][flag_index].merge( + 'flag_station_id' => flag_station_id, + 'room_id' => room_id + ) + end + end + end + end + + rewards + end + + def process_flag_rewards(flag_key, rewards) + results = [] + + rewards.each do |reward| + # Skip if already claimed + if @game.player_state['flag_rewards_claimed']&.include?(flag_key) + results << { type: 'skipped', reason: 'Already claimed' } + next + end + + # Process each reward type + case reward['type'] + when 'give_item' + results << process_item_reward(reward, flag_key) + + when 'unlock_door' + results << process_door_unlock_reward(reward, flag_key) + + when 'emit_event' + results << process_event_reward(reward, flag_key) + + else + results << { type: 'unknown', data: reward } + end + end + + # Mark rewards as claimed + @game.player_state['flag_rewards_claimed'] ||= [] + @game.player_state['flag_rewards_claimed'] << flag_key + @game.save! + + results + end + + def process_item_reward(reward, flag_key) + # Find the flag-station object to pull item from its itemsHeld + flag_station = find_flag_station_by_id(reward['flag_station_id']) + + return { type: 'error', message: 'Flag station not found' } unless flag_station + + # Get item from itemsHeld (similar to NPC item giving) + item = flag_station['itemsHeld']&.find { |i| i['type'] == reward['item_type'] || i['name'] == reward['item_name'] } + + return { type: 'error', message: 'Item not found in flag station' } unless item + + # Add to player inventory + @game.add_inventory_item!(item) + + { type: 'give_item', item: item, success: true } + end + + def process_door_unlock_reward(reward, flag_key) + room_id = reward['room_id'] || reward['target_room'] + + return { type: 'error', message: 'No room_id specified' } unless room_id + + # Unlock the door (same as NPC door unlock) + @game.unlock_room!(room_id) + + { type: 'unlock_door', room_id: room_id, success: true } + end + + def process_event_reward(reward, flag_key) + # Emit event (NPC can listen and trigger conversations) + event_name = reward['event_name'] || "flag_submitted:#{flag_key}" + + # Store event in player_state for client to emit + @game.player_state['pending_events'] ||= [] + @game.player_state['pending_events'] << { + 'name' => event_name, + 'data' => { 'flag' => flag_key, 'timestamp' => Time.current.to_i } + } + @game.save! + + { type: 'emit_event', event_name: event_name, success: true } + end + + def find_flag_station_by_id(flag_station_id) + @game.scenario_data['rooms']&.each do |_room_id, room| + room['objects']&.each do |obj| + return obj if (obj['id'] || obj['name']) == flag_station_id && obj['type'] == 'flag-station' + end + end + nil + end end end diff --git a/app/controllers/break_escape/missions_controller.rb b/app/controllers/break_escape/missions_controller.rb index b4014c7..8099533 100644 --- a/app/controllers/break_escape/missions_controller.rb +++ b/app/controllers/break_escape/missions_controller.rb @@ -20,13 +20,18 @@ module BreakEscape @mission = Mission.find(params[:id]) authorize @mission if defined?(Pundit) - # Create or find game instance for current player - @game = Game.find_or_create_by!( - player: current_player, - mission: @mission - ) - - redirect_to game_path(@game) + if @mission.requires_vms? && BreakEscape::Mission.hacktivity_mode? + # VM missions need explicit game creation with VM set selection + # Redirect to games#new which shows VM set selection UI + redirect_to new_game_path(mission_id: @mission.id) + else + # Legacy behavior for non-VM missions - auto-create game + @game = Game.find_or_create_by!( + player: current_player, + mission: @mission + ) + redirect_to game_path(@game) + end end end end diff --git a/app/models/break_escape/game.rb b/app/models/break_escape/game.rb index ea97a3f..d4545f7 100644 --- a/app/models/break_escape/game.rb +++ b/app/models/break_escape/game.rb @@ -543,7 +543,13 @@ module BreakEscape def generate_scenario_data # Only generate scenario data if it's not already set (e.g., in tests) - self.scenario_data ||= mission.generate_scenario_data + return if self.scenario_data.present? + + # Build VM context if vm_set_id is present in player_state + vm_context = build_vm_context + + # Generate with VM context (or empty context for non-VM missions) + self.scenario_data = mission.generate_scenario_data(vm_context) end def initialize_player_state @@ -579,10 +585,136 @@ module BreakEscape self.player_state['bluetoothDevices'] ||= [] self.player_state['notes'] ||= [] self.player_state['health'] ||= 100 + + # VM/Flag tracking fields + self.player_state['submitted_flags'] ||= [] # Array of submitted flag strings + self.player_state['flag_rewards_claimed'] ||= [] # Track claimed rewards + self.player_state['pending_events'] ||= [] # Events to emit on next sync end def set_started_at self.started_at ||= Time.current end + + # Build VM context from player_state vm_set_id (Hacktivity mode only) + def build_vm_context + vm_set_id = player_state&.dig('vm_set_id') + return {} unless vm_set_id && BreakEscape::Mission.hacktivity_mode? + + vm_set = ::VmSet.find_by(id: vm_set_id) + return {} unless vm_set + + # Build context hash for ERB template + { + 'vm_set_id' => vm_set.id, + 'vms' => vm_set.vms.map do |vm| + { + 'id' => vm.id, + 'title' => vm.title, + 'ip' => vm.ip_address, + 'enable_console' => vm.enable_console, + 'event_id' => vm.event_id, + 'sec_gen_batch_id' => vm.sec_gen_batch_id + } + end, + 'flags' => extract_flags_from_vm_set(vm_set), + 'hacktivity_mode' => true + } + end + + # Extract flags from VM set's SecGenBatch + def extract_flags_from_vm_set(vm_set) + return [] unless vm_set.sec_gen_batch&.flags.present? + + vm_set.sec_gen_batch.flags.map do |flag| + { + 'id' => flag.id, + 'value' => flag.flag, # The actual flag string + 'points' => flag.points + } + end + end + + public + + # ========================================== + # Flag Submission System + # ========================================== + + # Submit a CTF flag + def submit_flag(flag_key) + # Check if already submitted + if flag_submitted?(flag_key) + return { success: false, message: 'Flag already submitted' } + end + + # Validate flag exists in scenario + valid_flags = extract_valid_flags_from_scenario + unless valid_flags.any? { |f| f.downcase == flag_key.downcase } + return { success: false, message: 'Invalid flag' } + end + + # Submit to Hacktivity if in Hacktivity mode + if BreakEscape::Mission.hacktivity_mode? && player_state['vm_set_id'].present? + result = submit_to_hacktivity(flag_key) + return result unless result[:success] + end + + # Track submission + player_state['submitted_flags'] ||= [] + player_state['submitted_flags'] << flag_key + save! + + { success: true, message: 'Flag accepted!' } + end + + # Check if flag was already submitted + def flag_submitted?(flag_key) + player_state['submitted_flags']&.any? { |f| f.downcase == flag_key.downcase } + end + + private + + # Extract valid flags from scenario data (flag-station objects) + def extract_valid_flags_from_scenario + flags = [] + + # Check standalone flags first + if player_state['standalone_flags'].present? + flags.concat(player_state['standalone_flags']) + end + + # Extract from flag-station objects in scenario + scenario_data['rooms']&.each do |_room_id, room| + room['objects']&.each do |obj| + next unless obj['type'] == 'flag-station' + flags.concat(obj['flags']) if obj['flags'].is_a?(Array) + end + end + + flags.uniq + end + + # Submit flag to Hacktivity's FlagService + def submit_to_hacktivity(flag_key) + return { success: false, message: 'FlagService not available' } unless defined?(::FlagService) + + begin + # FlagService.process_flag requires: player, flag, flash + # We create a mock flash object since we're not in a controller context + mock_flash = {} + + result = ::FlagService.process_flag(player, flag_key, mock_flash) + + if result + { success: true, message: mock_flash[:notice] || 'Flag submitted to Hacktivity' } + else + { success: false, message: mock_flash[:alert] || 'Flag rejected by Hacktivity' } + end + rescue StandardError => e + Rails.logger.error "[BreakEscape] FlagService error: #{e.message}" + { success: false, message: 'Error submitting flag to Hacktivity' } + end + end end end diff --git a/app/models/break_escape/mission.rb b/app/models/break_escape/mission.rb index a2fcd20..55502c1 100644 --- a/app/models/break_escape/mission.rb +++ b/app/models/break_escape/mission.rb @@ -56,18 +56,46 @@ module BreakEscape end end - # Check if Hacktivity mode is available + # Check if Hacktivity mode is available (VMs and flag service) def self.hacktivity_mode? - defined?(::Cybok) + defined?(::VmSet) && defined?(::FlagService) end - # Generate scenario data via ERB - def generate_scenario_data + # Check if mission requires VMs (has secgen_scenario configured) + def requires_vms? + secgen_scenario.present? + end + + # Get valid VM sets for this mission (Hacktivity mode only) + # + # HACKTIVITY COMPATIBILITY NOTES: + # - Hacktivity uses `sec_gen_batch` (with underscore), not `secgen_batch` + # - The `scenario` field contains the XML path (e.g., "scenarios/ctf/foo.xml") + # - VmSet doesn't have a `display_name` method - use sec_gen_batch.title instead + # - Always eager-load :vms and :sec_gen_batch to avoid N+1 queries + def valid_vm_sets_for_user(user) + return [] unless self.class.hacktivity_mode? && requires_vms? + + # Query Hacktivity's vm_sets where: + # - scenario matches our secgen_scenario + # - user owns it (or is on the team) + # - not relinquished + # - build completed successfully + ::VmSet.joins(:sec_gen_batch) + .where(sec_gen_batches: { scenario: secgen_scenario }) + .where(user: user, relinquished: false) + .where.not(build_status: ['pending', 'error']) + .includes(:vms, :sec_gen_batch) + .order(created_at: :desc) + end + + # Generate scenario data via ERB with optional VM context + def generate_scenario_data(vm_context = {}) 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 + binding_context = ScenarioBinding.new(vm_context) output = erb.result(binding_context.get_binding) JSON.parse(output) @@ -77,13 +105,14 @@ module BreakEscape # Binding context for ERB variables class ScenarioBinding - def initialize + def initialize(vm_context = {}) @random_password = SecureRandom.alphanumeric(8) @random_pin = rand(1000..9999).to_s @random_code = SecureRandom.hex(4) + @vm_context = vm_context # VM/flag data for CTF integration end - attr_reader :random_password, :random_pin, :random_code + attr_reader :random_password, :random_pin, :random_code, :vm_context def get_binding binding diff --git a/app/policies/break_escape/game_policy.rb b/app/policies/break_escape/game_policy.rb index 85bf5f5..2b35010 100644 --- a/app/policies/break_escape/game_policy.rb +++ b/app/policies/break_escape/game_policy.rb @@ -49,6 +49,14 @@ module BreakEscape show? end + def container? + show? + end + + def submit_flag? + show? + end + class Scope < Scope def resolve if user&.admin? || user&.account_manager? diff --git a/app/policies/break_escape/mission_policy.rb b/app/policies/break_escape/mission_policy.rb index b2563b7..c4a34e8 100644 --- a/app/policies/break_escape/mission_policy.rb +++ b/app/policies/break_escape/mission_policy.rb @@ -9,6 +9,11 @@ module BreakEscape record.published? || user&.admin? || user&.account_manager? end + def create_game? + # Anyone authenticated can create a game for a mission they can view + user.present? && show? + end + class Scope < Scope def resolve if user&.admin? || user&.account_manager? diff --git a/app/views/break_escape/games/new.html.erb b/app/views/break_escape/games/new.html.erb new file mode 100644 index 0000000..4109c01 --- /dev/null +++ b/app/views/break_escape/games/new.html.erb @@ -0,0 +1,263 @@ +<%# Game Setup / VM Set Selection Page %> +
+
+

<%= @mission.display_name %>

+

<%= @mission.description %>

+
+ + <% if @mission.requires_vms? %> +
+

Select VM Environment

+ + <% if @available_vm_sets.any? %> +
+ <% @available_vm_sets.each do |vm_set| %> + <%= form_with url: break_escape.games_path, method: :post, local: true, class: 'vm-set-form' do |f| %> + <%= f.hidden_field :mission_id, value: @mission.id %> + <%= f.hidden_field :vm_set_id, value: vm_set.id %> + +
+
+ <%# NOTE: VmSet doesn't have display_name - use sec_gen_batch.title instead %> +

<%= vm_set.sec_gen_batch&.title || "VM Set ##{vm_set.id}" %>

+ <%= pluralize(vm_set.vms.count, 'VM') %> +
+ +
    + <% vm_set.vms.each do |vm| %> +
  • + <%= vm.title %> + <% if vm.ip_address.present? %> + (<%= vm.ip_address %>) + <% end %> + <% if vm.enable_console %> + 🖥️ + <% end %> +
  • + <% end %> +
+ + <%= f.submit "Start with this VM Set", class: "btn btn-primary" %> +
+ <% end %> + <% end %> +
+ + <% if @existing_games.any? %> +
+

Continue Existing Game

+
+ <% @existing_games.each do |game| %> + <%= link_to game_path(game), class: "btn btn-secondary existing-game-btn" do %> + Continue game started <%= time_ago_in_words(game.started_at) %> ago + <%= game.status %> + <% end %> + <% end %> +
+
+ <% end %> + + <% else %> +
+

No VM Sets Available

+

This mission requires VMs but you don't have any available VM sets.

+

Please provision VMs through Hacktivity first, then return here to start the mission.

+ <%= link_to "← Back to Missions", missions_path, class: "btn btn-secondary" %> +
+ <% end %> +
+ + <% else %> + <%# Non-VM mission - just start %> +
+ <%= form_with url: break_escape.games_path, method: :post, local: true do |f| %> + <%= f.hidden_field :mission_id, value: @mission.id %> + <%= f.submit "Start Mission", class: "btn btn-primary btn-large" %> + <% end %> +
+ <% end %> +
+ + + diff --git a/app/views/break_escape/games/show.html.erb b/app/views/break_escape/games/show.html.erb index 00ad540..6996e3d 100644 --- a/app/views/break_escape/games/show.html.erb +++ b/app/views/break_escape/games/show.html.erb @@ -52,6 +52,8 @@ + +
@@ -116,7 +118,9 @@ gameId: <%= @game.id %>, apiBasePath: '<%= game_path(@game) %>', assetsPath: '/break_escape/assets', - csrfToken: '<%= form_authenticity_token %>' + csrfToken: '<%= form_authenticity_token %>', + hacktivityMode: <%= BreakEscape::Mission.hacktivity_mode? %>, + vmSetId: <%= @game.player_state['vm_set_id'] || 'null' %> }; @@ -128,6 +132,11 @@ <%# Load game JavaScript (ES6 module) %> + <%# Load Hacktivity ActionCable integration for VM console support %> + <% if BreakEscape::Mission.hacktivity_mode? %> + + <% end %> + <%# Mobile touch handling %>