mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
WiP implementing VM integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
263
app/views/break_escape/games/new.html.erb
Normal file
263
app/views/break_escape/games/new.html.erb
Normal file
@@ -0,0 +1,263 @@
|
||||
<%# Game Setup / VM Set Selection Page %>
|
||||
<div class="game-setup container">
|
||||
<div class="game-setup-header">
|
||||
<h1><%= @mission.display_name %></h1>
|
||||
<p class="mission-description"><%= @mission.description %></p>
|
||||
</div>
|
||||
|
||||
<% if @mission.requires_vms? %>
|
||||
<div class="vm-selection">
|
||||
<h2>Select VM Environment</h2>
|
||||
|
||||
<% if @available_vm_sets.any? %>
|
||||
<div class="vm-set-list">
|
||||
<% @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 %>
|
||||
|
||||
<div class="vm-set-card">
|
||||
<div class="vm-set-header">
|
||||
<%# NOTE: VmSet doesn't have display_name - use sec_gen_batch.title instead %>
|
||||
<h3><%= vm_set.sec_gen_batch&.title || "VM Set ##{vm_set.id}" %></h3>
|
||||
<span class="vm-count"><%= pluralize(vm_set.vms.count, 'VM') %></span>
|
||||
</div>
|
||||
|
||||
<ul class="vm-list">
|
||||
<% vm_set.vms.each do |vm| %>
|
||||
<li class="vm-item">
|
||||
<span class="vm-title"><%= vm.title %></span>
|
||||
<% if vm.ip_address.present? %>
|
||||
<span class="vm-ip">(<%= vm.ip_address %>)</span>
|
||||
<% end %>
|
||||
<% if vm.enable_console %>
|
||||
<span class="vm-console-badge" title="Console available">🖥️</span>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= f.submit "Start with this VM Set", class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @existing_games.any? %>
|
||||
<div class="existing-games">
|
||||
<h3>Continue Existing Game</h3>
|
||||
<div class="existing-games-list">
|
||||
<% @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
|
||||
<span class="game-status badge"><%= game.status %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% else %>
|
||||
<div class="alert alert-warning">
|
||||
<h4>No VM Sets Available</h4>
|
||||
<p>This mission requires VMs but you don't have any available VM sets.</p>
|
||||
<p>Please provision VMs through Hacktivity first, then return here to start the mission.</p>
|
||||
<%= link_to "← Back to Missions", missions_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<%# Non-VM mission - just start %>
|
||||
<div class="mission-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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game-setup {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Press Start 2P', 'VT323', monospace;
|
||||
}
|
||||
|
||||
.game-setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.game-setup-header h1 {
|
||||
color: #00ff00;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mission-description {
|
||||
color: #cccccc;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.vm-selection h2 {
|
||||
color: #00ff00;
|
||||
border-bottom: 2px solid #00ff00;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.vm-set-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.vm-set-card {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #00ff00;
|
||||
padding: 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.vm-set-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.vm-set-header h3 {
|
||||
color: #00ff00;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.vm-count {
|
||||
background: #00aa00;
|
||||
color: #000;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.vm-item {
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
border-left: 3px solid #00ff00;
|
||||
color: #00ff00;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vm-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vm-ip {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-console-badge {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #00cc00;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #00aa00;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #333;
|
||||
color: #00ff00;
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 15px 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.existing-games {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.existing-games h3 {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.existing-games-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.existing-game-btn {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #333;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 20px;
|
||||
border: 2px solid;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: rgba(255, 165, 0, 0.1);
|
||||
border-color: #ffaa00;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.alert h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mission-start {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
<link rel="stylesheet" href="/break_escape/css/text-file-minigame.css">
|
||||
<link rel="stylesheet" href="/break_escape/css/npc-barks.css">
|
||||
<link rel="stylesheet" href="/break_escape/css/objectives.css">
|
||||
<link rel="stylesheet" href="/break_escape/css/vm-launcher-minigame.css">
|
||||
<link rel="stylesheet" href="/break_escape/css/flag-station-minigame.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container">
|
||||
@@ -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' %>
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -128,6 +132,11 @@
|
||||
<%# Load game JavaScript (ES6 module) %>
|
||||
<script type="module" src="/break_escape/js/main.js" nonce="<%= content_security_policy_nonce %>"></script>
|
||||
|
||||
<%# Load Hacktivity ActionCable integration for VM console support %>
|
||||
<% if BreakEscape::Mission.hacktivity_mode? %>
|
||||
<script type="module" src="/break_escape/js/systems/hacktivity-cable.js" nonce="<%= content_security_policy_nonce %>"></script>
|
||||
<% end %>
|
||||
|
||||
<%# Mobile touch handling %>
|
||||
<script>
|
||||
// Allow zooming on mobile devices
|
||||
|
||||
@@ -12,7 +12,7 @@ BreakEscape::Engine.routes.draw do
|
||||
resources :missions, only: [:index, :show]
|
||||
|
||||
# Game management
|
||||
resources :games, only: [:show, :create] do
|
||||
resources :games, only: [:new, :show, :create] do
|
||||
member do
|
||||
# Scenario and NPC data
|
||||
get 'scenario' # Returns full scenario_data JSON (for compatibility)
|
||||
@@ -30,6 +30,9 @@ BreakEscape::Engine.routes.draw do
|
||||
get 'objectives' # Get current objective state
|
||||
post 'objectives/tasks/:task_id', to: 'games#complete_task', as: 'complete_task'
|
||||
put 'objectives/tasks/:task_id', to: 'games#update_task_progress', as: 'update_task_progress'
|
||||
|
||||
# VM/Flag integration
|
||||
post 'flags', to: 'games#submit_flag' # Submit CTF flag for validation
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
19
db/migrate/20251128000001_remove_unique_game_constraint.rb
Normal file
19
db/migrate/20251128000001_remove_unique_game_constraint.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Remove unique constraint on games to allow multiple games per player+mission
|
||||
# This is needed for VM/CTF flag integration where each VM set gets its own game instance
|
||||
class RemoveUniqueGameConstraint < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
# Remove the unique index
|
||||
remove_index :break_escape_games,
|
||||
name: 'index_games_on_player_and_mission',
|
||||
if_exists: true
|
||||
|
||||
# Add non-unique index for performance
|
||||
# This maintains query performance without enforcing uniqueness
|
||||
add_index :break_escape_games,
|
||||
[:player_type, :player_id, :mission_id],
|
||||
name: 'index_games_on_player_and_mission_non_unique'
|
||||
end
|
||||
end
|
||||
|
||||
184
public/break_escape/css/flag-station-minigame.css
Normal file
184
public/break_escape/css/flag-station-minigame.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Flag Station Minigame Styles
|
||||
*/
|
||||
|
||||
.flag-station {
|
||||
padding: 20px;
|
||||
font-family: 'VT323', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.flag-station-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.flag-station-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.flag-station-description {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.flag-input-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.flag-input-label {
|
||||
display: block;
|
||||
color: #00ff00;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flag-input-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flag-input {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
border: 2px solid #333;
|
||||
color: #00ff00;
|
||||
padding: 12px 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.flag-input:focus {
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.flag-input::placeholder {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.flag-submit-btn {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 12px 20px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flag-submit-btn:hover:not(:disabled) {
|
||||
background: #00cc00;
|
||||
}
|
||||
|
||||
.flag-submit-btn:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.flag-result {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flag-result.success {
|
||||
display: block;
|
||||
background: rgba(0, 170, 0, 0.2);
|
||||
border: 2px solid #00aa00;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.flag-result.error {
|
||||
display: block;
|
||||
background: rgba(170, 0, 0, 0.2);
|
||||
border: 2px solid #aa0000;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.flag-result.loading {
|
||||
display: block;
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
border: 2px solid #ffaa00;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.flag-history {
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.flag-history-title {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.flag-history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.flag-history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
border-left: 3px solid #00aa00;
|
||||
}
|
||||
|
||||
.flag-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff00;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flag-check {
|
||||
color: #00aa00;
|
||||
}
|
||||
|
||||
.reward-notification {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 136, 255, 0.1);
|
||||
border: 2px solid #0088ff;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reward-notification h4 {
|
||||
color: #0088ff;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.reward-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.no-flags-yet {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
190
public/break_escape/css/vm-launcher-minigame.css
Normal file
190
public/break_escape/css/vm-launcher-minigame.css
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* VM Launcher Minigame Styles
|
||||
*/
|
||||
|
||||
.vm-launcher {
|
||||
padding: 15px;
|
||||
font-family: 'VT323', 'Courier New', monospace;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vm-launcher-description {
|
||||
color: #888;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vm-card {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.vm-card:hover {
|
||||
border-color: #00ff00;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.vm-card.selected {
|
||||
border-color: #00ff00;
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.vm-card.launching {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.vm-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vm-title {
|
||||
color: #00ff00;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vm-status {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.vm-status.online {
|
||||
background: #00aa00;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.vm-status.offline {
|
||||
background: #aa0000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vm-status.console {
|
||||
background: #0088ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vm-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.vm-detail-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.vm-ip {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.vm-actions {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vm-action-btn {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vm-action-btn:hover:not(:disabled) {
|
||||
background: #00cc00;
|
||||
}
|
||||
|
||||
.vm-action-btn:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vm-action-btn.launching {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.launch-status {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.launch-status.success {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.launch-status.error {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.launch-status.loading {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.no-vms-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.no-vms-message h4 {
|
||||
color: #ffaa00;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.standalone-instructions {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.standalone-instructions h4 {
|
||||
color: #00ff00;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.standalone-instructions code {
|
||||
background: #000;
|
||||
padding: 2px 6px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.standalone-instructions ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.standalone-instructions li {
|
||||
margin: 8px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
@@ -512,6 +512,14 @@ export async function create() {
|
||||
console.log('📋 Restored objectives state from server');
|
||||
}
|
||||
|
||||
// Restore submitted flags from server if available (for flag-station minigame)
|
||||
if (gameScenario.submittedFlags) {
|
||||
window.gameState.submittedFlags = gameScenario.submittedFlags;
|
||||
console.log('🏁 Restored submitted flags from server:', window.gameState.submittedFlags);
|
||||
} else {
|
||||
window.gameState.submittedFlags = [];
|
||||
}
|
||||
|
||||
// Initialize objectives system AFTER scenario is loaded
|
||||
// This must happen in create() because gameScenario isn't available until now
|
||||
if (gameScenario.objectives && window.objectivesManager) {
|
||||
|
||||
@@ -54,7 +54,8 @@ window.gameState = {
|
||||
biometricUnlocks: [],
|
||||
bluetoothDevices: [],
|
||||
notes: [],
|
||||
startTime: null
|
||||
startTime: null,
|
||||
submittedFlags: [] // CTF flags that have been submitted
|
||||
};
|
||||
window.lastBluetoothScan = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Flag Station Minigame
|
||||
*
|
||||
* CTF flag submission interface.
|
||||
* Players can submit flags they've found and receive in-game rewards.
|
||||
*/
|
||||
|
||||
import { MinigameScene } from '../framework/base-minigame.js';
|
||||
|
||||
export class FlagStationMinigame extends MinigameScene {
|
||||
constructor(container, params) {
|
||||
super(container, params);
|
||||
this.stationId = params.stationId || 'flag-station';
|
||||
this.stationName = params.stationName || 'Flag Submission Terminal';
|
||||
this.expectedFlags = params.flags || [];
|
||||
this.submittedFlags = params.submittedFlags || window.gameState?.submittedFlags || [];
|
||||
this.gameId = params.gameId || window.gameConfig?.gameId;
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.params.title = this.stationName;
|
||||
this.params.cancelText = 'Close';
|
||||
super.init();
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
// Add custom styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.flag-station {
|
||||
padding: 20px;
|
||||
font-family: 'VT323', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.flag-station-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.flag-station-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.flag-station-description {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.flag-input-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.flag-input-label {
|
||||
display: block;
|
||||
color: #00ff00;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flag-input-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flag-input {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
border: 2px solid #333;
|
||||
color: #00ff00;
|
||||
padding: 12px 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.flag-input:focus {
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.flag-input::placeholder {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.flag-submit-btn {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 12px 20px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flag-submit-btn:hover:not(:disabled) {
|
||||
background: #00cc00;
|
||||
}
|
||||
|
||||
.flag-submit-btn:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.flag-result {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flag-result.success {
|
||||
display: block;
|
||||
background: rgba(0, 170, 0, 0.2);
|
||||
border: 2px solid #00aa00;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.flag-result.error {
|
||||
display: block;
|
||||
background: rgba(170, 0, 0, 0.2);
|
||||
border: 2px solid #aa0000;
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.flag-result.loading {
|
||||
display: block;
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
border: 2px solid #ffaa00;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.flag-history {
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.flag-history-title {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.flag-history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.flag-history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin: 5px 0;
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
border-left: 3px solid #00aa00;
|
||||
}
|
||||
|
||||
.flag-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff00;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flag-check {
|
||||
color: #00aa00;
|
||||
}
|
||||
|
||||
.reward-notification {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 136, 255, 0.1);
|
||||
border: 2px solid #0088ff;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reward-notification h4 {
|
||||
color: #0088ff;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.reward-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.no-flags-yet {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
this.gameContainer.appendChild(style);
|
||||
|
||||
// Build main container
|
||||
const station = document.createElement('div');
|
||||
station.className = 'flag-station';
|
||||
station.innerHTML = this.buildStationContent();
|
||||
|
||||
this.gameContainer.appendChild(station);
|
||||
this.attachEventHandlers();
|
||||
}
|
||||
|
||||
buildStationContent() {
|
||||
const submittedCount = this.submittedFlags.length;
|
||||
const totalCount = this.expectedFlags.length;
|
||||
const progressText = totalCount > 0
|
||||
? `${submittedCount}/${totalCount} flags submitted`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="flag-station-header">
|
||||
<div class="flag-station-icon">🏁</div>
|
||||
<p class="flag-station-description">
|
||||
Enter captured CTF flags below to validate your findings.
|
||||
${progressText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flag-input-container">
|
||||
<label class="flag-input-label">Enter Flag:</label>
|
||||
<div class="flag-input-wrapper">
|
||||
<input type="text"
|
||||
class="flag-input"
|
||||
id="flag-input"
|
||||
placeholder="flag{...}"
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
<button class="flag-submit-btn" id="flag-submit-btn">SUBMIT</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flag-result" id="flag-result"></div>
|
||||
<div class="reward-notification" id="reward-notification" style="display: none;"></div>
|
||||
|
||||
<div class="flag-history">
|
||||
<div class="flag-history-title">Submitted Flags</div>
|
||||
<ul class="flag-history-list" id="flag-history-list">
|
||||
${this.buildFlagHistory()}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
buildFlagHistory() {
|
||||
if (this.submittedFlags.length === 0) {
|
||||
return '<li class="no-flags-yet">No flags submitted yet</li>';
|
||||
}
|
||||
|
||||
return this.submittedFlags.map(flag => `
|
||||
<li class="flag-history-item">
|
||||
<span class="flag-value">${this.escapeHtml(flag)}</span>
|
||||
<span class="flag-check">✓</span>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
attachEventHandlers() {
|
||||
const input = this.gameContainer.querySelector('#flag-input');
|
||||
const submitBtn = this.gameContainer.querySelector('#flag-submit-btn');
|
||||
|
||||
// Submit on button click
|
||||
this.addEventListener(submitBtn, 'click', () => this.submitFlag());
|
||||
|
||||
// Submit on Enter key
|
||||
this.addEventListener(input, 'keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.submitFlag();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus input on start
|
||||
setTimeout(() => input.focus(), 100);
|
||||
}
|
||||
|
||||
async submitFlag() {
|
||||
if (this.isSubmitting) return;
|
||||
|
||||
const input = this.gameContainer.querySelector('#flag-input');
|
||||
const submitBtn = this.gameContainer.querySelector('#flag-submit-btn');
|
||||
const resultEl = this.gameContainer.querySelector('#flag-result');
|
||||
const rewardEl = this.gameContainer.querySelector('#reward-notification');
|
||||
|
||||
const flagValue = input.value.trim();
|
||||
|
||||
if (!flagValue) {
|
||||
this.showResult(resultEl, 'error', 'Please enter a flag');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already submitted
|
||||
if (this.submittedFlags.some(f => f.toLowerCase() === flagValue.toLowerCase())) {
|
||||
this.showResult(resultEl, 'error', 'This flag has already been submitted');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '...';
|
||||
this.showResult(resultEl, 'loading', 'Validating flag...');
|
||||
rewardEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/break_escape/games/${this.gameId}/flags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ flag: flagValue })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Success!
|
||||
this.showResult(resultEl, 'success', `✓ ${data.message || 'Flag accepted!'}`);
|
||||
|
||||
// Add to history
|
||||
this.submittedFlags.push(flagValue);
|
||||
this.updateFlagHistory();
|
||||
|
||||
// Update global state
|
||||
if (window.gameState) {
|
||||
window.gameState.submittedFlags = this.submittedFlags;
|
||||
}
|
||||
|
||||
// Show rewards if any
|
||||
if (data.rewards && data.rewards.length > 0) {
|
||||
this.showRewards(rewardEl, data.rewards);
|
||||
|
||||
// Emit events for rewards
|
||||
this.processRewardEvents(data.rewards);
|
||||
}
|
||||
|
||||
// Clear input
|
||||
input.value = '';
|
||||
|
||||
} else {
|
||||
this.showResult(resultEl, 'error', `✗ ${data.message || 'Invalid flag'}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FlagStation] Submit error:', error);
|
||||
this.showResult(resultEl, 'error', '✗ Failed to submit flag. Please try again.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'SUBMIT';
|
||||
}
|
||||
}
|
||||
|
||||
showResult(element, type, message) {
|
||||
element.className = `flag-result ${type}`;
|
||||
element.textContent = message;
|
||||
element.style.display = 'block';
|
||||
}
|
||||
|
||||
showRewards(element, rewards) {
|
||||
const rewardHtml = rewards.map(reward => {
|
||||
switch (reward.type) {
|
||||
case 'give_item':
|
||||
return `
|
||||
<div class="reward-item">
|
||||
<span class="reward-icon">📦</span>
|
||||
<span>Received: ${reward.item?.name || 'Item'}</span>
|
||||
</div>
|
||||
`;
|
||||
case 'unlock_door':
|
||||
return `
|
||||
<div class="reward-item">
|
||||
<span class="reward-icon">🔓</span>
|
||||
<span>Door unlocked: ${reward.room_id}</span>
|
||||
</div>
|
||||
`;
|
||||
case 'emit_event':
|
||||
return `
|
||||
<div class="reward-item">
|
||||
<span class="reward-icon">⚡</span>
|
||||
<span>Event triggered</span>
|
||||
</div>
|
||||
`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}).filter(h => h).join('');
|
||||
|
||||
if (rewardHtml) {
|
||||
element.innerHTML = `<h4>🎁 Rewards Unlocked!</h4>${rewardHtml}`;
|
||||
element.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
processRewardEvents(rewards) {
|
||||
for (const reward of rewards) {
|
||||
if (reward.type === 'give_item' && reward.item) {
|
||||
// Emit item received event
|
||||
if (window.eventDispatcher) {
|
||||
window.eventDispatcher.emit('item_received', {
|
||||
item: reward.item,
|
||||
source: 'flag_reward'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reward.type === 'unlock_door' && reward.room_id) {
|
||||
// Emit door unlocked event
|
||||
if (window.eventDispatcher) {
|
||||
window.eventDispatcher.emit('door_unlocked', {
|
||||
roomId: reward.room_id,
|
||||
source: 'flag_reward'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reward.type === 'emit_event' && reward.event_name) {
|
||||
// Emit the custom event
|
||||
if (window.eventDispatcher) {
|
||||
window.eventDispatcher.emit(reward.event_name, {
|
||||
source: 'flag_reward'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFlagHistory() {
|
||||
const list = this.gameContainer.querySelector('#flag-history-list');
|
||||
list.innerHTML = this.buildFlagHistory();
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
start() {
|
||||
super.start();
|
||||
console.log('[FlagStation] Started with', this.expectedFlags.length, 'expected flags');
|
||||
}
|
||||
}
|
||||
|
||||
// Register with MinigameFramework
|
||||
if (window.MinigameFramework) {
|
||||
window.MinigameFramework.registerMinigame('flag-station', FlagStationMinigame);
|
||||
}
|
||||
|
||||
export default FlagStationMinigame;
|
||||
|
||||
@@ -16,6 +16,8 @@ export { PasswordMinigame } from './password/password-minigame.js';
|
||||
export { TextFileMinigame, returnToTextFileAfterNotes } from './text-file/text-file-minigame.js';
|
||||
export { TitleScreenMinigame, startTitleScreenMinigame } from './title-screen/title-screen-minigame.js';
|
||||
export { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID } from './rfid/rfid-minigame.js';
|
||||
export { VmLauncherMinigame } from './vm-launcher/vm-launcher-minigame.js';
|
||||
export { FlagStationMinigame } from './flag-station/flag-station-minigame.js';
|
||||
|
||||
// Initialize the global minigame framework for backward compatibility
|
||||
import { MinigameFramework } from './framework/minigame-manager.js';
|
||||
@@ -77,6 +79,12 @@ import { TitleScreenMinigame, startTitleScreenMinigame } from './title-screen/ti
|
||||
// Import the RFID minigame
|
||||
import { RFIDMinigame, startRFIDMinigame, returnToConversationAfterRFID } from './rfid/rfid-minigame.js';
|
||||
|
||||
// Import the VM launcher minigame
|
||||
import { VmLauncherMinigame } from './vm-launcher/vm-launcher-minigame.js';
|
||||
|
||||
// Import the flag station minigame
|
||||
import { FlagStationMinigame } from './flag-station/flag-station-minigame.js';
|
||||
|
||||
// Register minigames
|
||||
MinigameFramework.registerScene('lockpicking', LockpickingMinigamePhaser); // Use Phaser version as default
|
||||
MinigameFramework.registerScene('lockpicking-phaser', LockpickingMinigamePhaser); // Keep explicit phaser name
|
||||
@@ -92,6 +100,8 @@ MinigameFramework.registerScene('password', PasswordMinigame);
|
||||
MinigameFramework.registerScene('text-file', TextFileMinigame);
|
||||
MinigameFramework.registerScene('title-screen', TitleScreenMinigame);
|
||||
MinigameFramework.registerScene('rfid', RFIDMinigame);
|
||||
MinigameFramework.registerScene('vm-launcher', VmLauncherMinigame);
|
||||
MinigameFramework.registerScene('flag-station', FlagStationMinigame);
|
||||
|
||||
// Make minigame functions available globally
|
||||
window.startNotesMinigame = startNotesMinigame;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* VM Launcher Minigame
|
||||
*
|
||||
* Displays available VMs and allows launching console connections.
|
||||
* Works in two modes:
|
||||
* - Hacktivity mode: Downloads SPICE console files via ActionCable
|
||||
* - Standalone mode: Shows VirtualBox instructions
|
||||
*/
|
||||
|
||||
import { MinigameScene } from '../framework/base-minigame.js';
|
||||
|
||||
export class VmLauncherMinigame extends MinigameScene {
|
||||
constructor(container, params) {
|
||||
super(container, params);
|
||||
this.vms = params.vms || [];
|
||||
this.hacktivityMode = params.hacktivityMode || false;
|
||||
this.selectedVm = null;
|
||||
this.isLaunching = false;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.params.title = this.params.title || 'VM Console Access';
|
||||
this.params.cancelText = 'Close';
|
||||
super.init();
|
||||
this.buildUI();
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
// Add custom styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.vm-launcher {
|
||||
padding: 15px;
|
||||
font-family: 'VT323', 'Courier New', monospace;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vm-launcher-description {
|
||||
color: #888;
|
||||
margin-bottom: 15px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vm-card {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.vm-card:hover {
|
||||
border-color: #00ff00;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.vm-card.selected {
|
||||
border-color: #00ff00;
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.vm-card.launching {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.vm-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vm-title {
|
||||
color: #00ff00;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vm-status {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.vm-status.online {
|
||||
background: #00aa00;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.vm-status.offline {
|
||||
background: #aa0000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vm-status.console {
|
||||
background: #0088ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vm-details {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.vm-detail-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.vm-ip {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.vm-actions {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vm-action-btn {
|
||||
background: #00aa00;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vm-action-btn:hover:not(:disabled) {
|
||||
background: #00cc00;
|
||||
}
|
||||
|
||||
.vm-action-btn:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vm-action-btn.launching {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.launch-status {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.launch-status.success {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.launch-status.error {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
.launch-status.loading {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.no-vms-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.no-vms-message h4 {
|
||||
color: #ffaa00;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.standalone-instructions {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.standalone-instructions h4 {
|
||||
color: #00ff00;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.standalone-instructions code {
|
||||
background: #000;
|
||||
padding: 2px 6px;
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
||||
.standalone-instructions ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.standalone-instructions li {
|
||||
margin: 8px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
`;
|
||||
this.gameContainer.appendChild(style);
|
||||
|
||||
// Build main container
|
||||
const launcher = document.createElement('div');
|
||||
launcher.className = 'vm-launcher';
|
||||
|
||||
if (this.vms.length === 0) {
|
||||
launcher.innerHTML = this.buildNoVmsMessage();
|
||||
} else {
|
||||
launcher.innerHTML = this.buildVmList();
|
||||
}
|
||||
|
||||
this.gameContainer.appendChild(launcher);
|
||||
this.attachEventHandlers();
|
||||
}
|
||||
|
||||
buildNoVmsMessage() {
|
||||
if (this.hacktivityMode) {
|
||||
return `
|
||||
<div class="no-vms-message">
|
||||
<h4>No VMs Available</h4>
|
||||
<p>No virtual machines are configured for this mission.</p>
|
||||
<p>Please provision VMs through Hacktivity first.</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
<div class="no-vms-message">
|
||||
<h4>Standalone Mode</h4>
|
||||
<p>VMs are not available in standalone mode.</p>
|
||||
${this.buildStandaloneInstructions()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
buildVmList() {
|
||||
const description = this.hacktivityMode
|
||||
? 'Select a VM to open its console. A SPICE viewer file will be downloaded.'
|
||||
: 'These VMs are available for this mission.';
|
||||
|
||||
let html = `
|
||||
<p class="vm-launcher-description">${description}</p>
|
||||
<div class="vm-list">
|
||||
`;
|
||||
|
||||
for (const vm of this.vms) {
|
||||
const hasConsole = vm.enable_console !== false;
|
||||
const statusClass = hasConsole ? 'console' : 'online';
|
||||
const statusText = hasConsole ? 'Console' : 'Active';
|
||||
|
||||
html += `
|
||||
<div class="vm-card" data-vm-id="${vm.id}">
|
||||
<div class="vm-header">
|
||||
<span class="vm-title">${this.escapeHtml(vm.title)}</span>
|
||||
<span class="vm-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="vm-details">
|
||||
${vm.ip ? `<span><span class="vm-detail-label">IP:</span> <span class="vm-ip">${this.escapeHtml(vm.ip)}</span></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (this.hacktivityMode) {
|
||||
html += `
|
||||
<div class="vm-actions">
|
||||
<button class="vm-action-btn" id="launch-console-btn" disabled>
|
||||
Select a VM
|
||||
</button>
|
||||
</div>
|
||||
<div class="launch-status" id="launch-status"></div>
|
||||
`;
|
||||
} else {
|
||||
html += this.buildStandaloneInstructions();
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
buildStandaloneInstructions() {
|
||||
return `
|
||||
<div class="standalone-instructions">
|
||||
<h4>VirtualBox Instructions</h4>
|
||||
<ol>
|
||||
<li>Open <code>VirtualBox</code> on your local machine</li>
|
||||
<li>Import the mission VM file (.ova)</li>
|
||||
<li>Start the VM and wait for it to boot</li>
|
||||
<li>Note the VM's IP address (shown on login screen)</li>
|
||||
<li>Return to this game and complete objectives</li>
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachEventHandlers() {
|
||||
// VM card selection
|
||||
const vmCards = this.gameContainer.querySelectorAll('.vm-card');
|
||||
vmCards.forEach(card => {
|
||||
this.addEventListener(card, 'click', () => this.selectVm(card));
|
||||
});
|
||||
|
||||
// Launch button (Hacktivity mode only)
|
||||
const launchBtn = this.gameContainer.querySelector('#launch-console-btn');
|
||||
if (launchBtn) {
|
||||
this.addEventListener(launchBtn, 'click', () => this.launchConsole());
|
||||
}
|
||||
}
|
||||
|
||||
selectVm(card) {
|
||||
if (this.isLaunching) return;
|
||||
|
||||
// Clear previous selection
|
||||
this.gameContainer.querySelectorAll('.vm-card').forEach(c => {
|
||||
c.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Select new card
|
||||
card.classList.add('selected');
|
||||
this.selectedVm = this.vms.find(vm => vm.id == card.dataset.vmId);
|
||||
|
||||
// Update launch button
|
||||
const launchBtn = this.gameContainer.querySelector('#launch-console-btn');
|
||||
if (launchBtn && this.selectedVm) {
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.textContent = `Open Console: ${this.selectedVm.title}`;
|
||||
}
|
||||
}
|
||||
|
||||
async launchConsole() {
|
||||
if (!this.selectedVm || this.isLaunching) return;
|
||||
|
||||
this.isLaunching = true;
|
||||
const launchBtn = this.gameContainer.querySelector('#launch-console-btn');
|
||||
const statusEl = this.gameContainer.querySelector('#launch-status');
|
||||
const vmCard = this.gameContainer.querySelector(`[data-vm-id="${this.selectedVm.id}"]`);
|
||||
|
||||
launchBtn.disabled = true;
|
||||
launchBtn.classList.add('launching');
|
||||
launchBtn.textContent = 'Connecting...';
|
||||
vmCard.classList.add('launching');
|
||||
statusEl.className = 'launch-status loading';
|
||||
statusEl.textContent = 'Requesting console file...';
|
||||
|
||||
try {
|
||||
if (window.hacktivityCable) {
|
||||
// Use ActionCable integration
|
||||
const result = await window.hacktivityCable.requestConsoleFile(
|
||||
this.selectedVm.id,
|
||||
this.selectedVm.event_id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
window.hacktivityCable.downloadConsoleFile({
|
||||
filename: result.filename,
|
||||
content: result.content,
|
||||
contentType: result.contentType
|
||||
});
|
||||
|
||||
statusEl.className = 'launch-status success';
|
||||
statusEl.textContent = '✓ Console file downloaded! Open it with a SPICE viewer.';
|
||||
}
|
||||
} else {
|
||||
throw new Error('ActionCable not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VmLauncher] Launch failed:', error);
|
||||
statusEl.className = 'launch-status error';
|
||||
statusEl.textContent = `✗ Failed: ${error.message}`;
|
||||
} finally {
|
||||
this.isLaunching = false;
|
||||
launchBtn.disabled = false;
|
||||
launchBtn.classList.remove('launching');
|
||||
launchBtn.textContent = `Open Console: ${this.selectedVm.title}`;
|
||||
vmCard.classList.remove('launching');
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
start() {
|
||||
super.start();
|
||||
console.log('[VmLauncher] Started with', this.vms.length, 'VMs');
|
||||
}
|
||||
}
|
||||
|
||||
// Register with MinigameFramework
|
||||
if (window.MinigameFramework) {
|
||||
window.MinigameFramework.registerMinigame('vm-launcher', VmLauncherMinigame);
|
||||
}
|
||||
|
||||
export default VmLauncherMinigame;
|
||||
|
||||
221
public/break_escape/js/systems/hacktivity-cable.js
Normal file
221
public/break_escape/js/systems/hacktivity-cable.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Hacktivity ActionCable Integration
|
||||
*
|
||||
* Handles real-time communication with Hacktivity's ActionCable channels
|
||||
* for VM console file delivery and other asynchronous events.
|
||||
*
|
||||
* This module is only loaded when in Hacktivity mode.
|
||||
*/
|
||||
|
||||
class HacktivityCable {
|
||||
constructor() {
|
||||
this.cable = null;
|
||||
this.consoleChannel = null;
|
||||
this.pendingConsoleRequests = new Map(); // requestId -> { resolve, reject, timeout }
|
||||
this.consoleRequestCounter = 0;
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ActionCable connection
|
||||
*/
|
||||
initialize() {
|
||||
// Check if ActionCable is available (loaded by Rails/Hacktivity)
|
||||
if (typeof ActionCable === 'undefined') {
|
||||
console.warn('[HacktivityCable] ActionCable not available - console features disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create cable consumer
|
||||
this.cable = ActionCable.createConsumer();
|
||||
|
||||
// Subscribe to console channel
|
||||
this.subscribeToConsoleChannel();
|
||||
|
||||
console.log('[HacktivityCable] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the VM console channel
|
||||
*/
|
||||
subscribeToConsoleChannel() {
|
||||
if (!this.cable) return;
|
||||
|
||||
this.consoleChannel = this.cable.subscriptions.create(
|
||||
{ channel: 'ConsoleChannel' },
|
||||
{
|
||||
connected: () => {
|
||||
console.log('[HacktivityCable] Connected to ConsoleChannel');
|
||||
},
|
||||
|
||||
disconnected: () => {
|
||||
console.log('[HacktivityCable] Disconnected from ConsoleChannel');
|
||||
},
|
||||
|
||||
received: (data) => {
|
||||
this.handleConsoleData(data);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received console data
|
||||
* @param {Object} data - Console file data from ActionCable
|
||||
*/
|
||||
handleConsoleData(data) {
|
||||
console.log('[HacktivityCable] Received console data:', data);
|
||||
|
||||
// Expected format from Hacktivity:
|
||||
// { type: 'console_file', vm_id: 123, filename: 'console.vv', content: '...base64...' }
|
||||
if (data.type === 'console_file') {
|
||||
// Find pending request for this VM
|
||||
const pendingKey = `vm_${data.vm_id}`;
|
||||
const pending = this.pendingConsoleRequests.get(pendingKey);
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingConsoleRequests.delete(pendingKey);
|
||||
pending.resolve({
|
||||
success: true,
|
||||
filename: data.filename,
|
||||
content: data.content,
|
||||
contentType: data.content_type || 'application/x-virt-viewer'
|
||||
});
|
||||
} else {
|
||||
// No pending request - may be a broadcast or late response
|
||||
// Trigger download anyway
|
||||
this.downloadConsoleFile(data);
|
||||
}
|
||||
} else if (data.type === 'console_error') {
|
||||
const pendingKey = `vm_${data.vm_id}`;
|
||||
const pending = this.pendingConsoleRequests.get(pendingKey);
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingConsoleRequests.delete(pendingKey);
|
||||
pending.reject(new Error(data.message || 'Console file generation failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request console file for a VM
|
||||
* @param {number} vmId - The VM ID
|
||||
* @param {number} eventId - The event ID (for Hacktivity's event context)
|
||||
* @returns {Promise<Object>} - Promise resolving to console file data
|
||||
*/
|
||||
requestConsoleFile(vmId, eventId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.consoleChannel) {
|
||||
reject(new Error('Console channel not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingKey = `vm_${vmId}`;
|
||||
|
||||
// Set timeout for request
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingConsoleRequests.delete(pendingKey);
|
||||
reject(new Error('Console file request timed out'));
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Store pending request
|
||||
this.pendingConsoleRequests.set(pendingKey, { resolve, reject, timeout });
|
||||
|
||||
// Send request to server via AJAX (ActionCable receives the response)
|
||||
fetch(`/events/${eventId}/vms/${vmId}/console`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': this.getCsrfToken()
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
// Response just acknowledges request - actual file comes via ActionCable
|
||||
console.log('[HacktivityCable] Console request acknowledged');
|
||||
})
|
||||
.catch(error => {
|
||||
clearTimeout(timeout);
|
||||
this.pendingConsoleRequests.delete(pendingKey);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download console file to user's device
|
||||
* @param {Object} data - Console file data
|
||||
*/
|
||||
downloadConsoleFile(data) {
|
||||
try {
|
||||
// Decode base64 content
|
||||
const binaryString = atob(data.content);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([bytes], {
|
||||
type: data.contentType || 'application/x-virt-viewer'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = data.filename || 'console.vv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[HacktivityCable] Console file downloaded:', data.filename);
|
||||
} catch (error) {
|
||||
console.error('[HacktivityCable] Failed to download console file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token from meta tag
|
||||
* @returns {string} CSRF token
|
||||
*/
|
||||
getCsrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from ActionCable
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.consoleChannel) {
|
||||
this.consoleChannel.unsubscribe();
|
||||
this.consoleChannel = null;
|
||||
}
|
||||
if (this.cable) {
|
||||
this.cable.disconnect();
|
||||
this.cable = null;
|
||||
}
|
||||
|
||||
// Clean up pending requests
|
||||
for (const [key, pending] of this.pendingConsoleRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Disconnected'));
|
||||
}
|
||||
this.pendingConsoleRequests.clear();
|
||||
|
||||
console.log('[HacktivityCable] Disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.hacktivityCable = new HacktivityCable();
|
||||
|
||||
// Export for module usage
|
||||
export default window.hacktivityCable;
|
||||
|
||||
@@ -629,6 +629,40 @@ export function handleObjectInteraction(sprite) {
|
||||
// If it's not in inventory, let it fall through to the takeable logic below
|
||||
}
|
||||
|
||||
// Handle VM Launcher interaction
|
||||
if (sprite.scenarioData.type === "vm-launcher" || sprite.scenarioData.type === "vm_launcher") {
|
||||
console.log('VM Launcher interaction:', sprite.scenarioData);
|
||||
if (window.MinigameFramework) {
|
||||
// Get VM data from scenario or gameConfig
|
||||
const vms = sprite.scenarioData.vms || window.gameConfig?.vms || [];
|
||||
const hacktivityMode = sprite.scenarioData.hacktivityMode || window.gameConfig?.hacktivityMode || false;
|
||||
|
||||
window.MinigameFramework.startMinigame('vm-launcher', null, {
|
||||
title: sprite.scenarioData.name || 'VM Console Access',
|
||||
vms: vms,
|
||||
hacktivityMode: hacktivityMode,
|
||||
stationId: sprite.scenarioData.id || sprite.objectId
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Flag Station interaction
|
||||
if (sprite.scenarioData.type === "flag-station" || sprite.scenarioData.type === "flag_station") {
|
||||
console.log('Flag Station interaction:', sprite.scenarioData);
|
||||
if (window.MinigameFramework) {
|
||||
window.MinigameFramework.startMinigame('flag-station', null, {
|
||||
title: sprite.scenarioData.name || 'Flag Submission Terminal',
|
||||
stationId: sprite.scenarioData.id || sprite.objectId,
|
||||
stationName: sprite.scenarioData.name,
|
||||
flags: sprite.scenarioData.flags || [],
|
||||
submittedFlags: window.gameState?.submittedFlags || [],
|
||||
gameId: window.gameConfig?.gameId
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the Lockpick Set - pick it up if takeable, or use it if in inventory
|
||||
if (sprite.scenarioData.type === "lockpick" || sprite.scenarioData.type === "lockpickset") {
|
||||
// If it's in inventory (marked as non-takeable), just acknowledge it
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_11_25_100000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_11_28_000001) do
|
||||
create_table "break_escape_cyboks", force: :cascade do |t|
|
||||
t.string "ka"
|
||||
t.string "topic"
|
||||
@@ -47,7 +47,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_11_25_100000) do
|
||||
t.integer "objectives_completed", default: 0
|
||||
t.integer "tasks_completed", default: 0
|
||||
t.index ["mission_id"], name: "index_break_escape_games_on_mission_id"
|
||||
t.index ["player_type", "player_id", "mission_id"], name: "index_games_on_player_and_mission", unique: true
|
||||
t.index ["player_type", "player_id", "mission_id"], name: "index_games_on_player_and_mission_non_unique"
|
||||
t.index ["player_type", "player_id"], name: "index_break_escape_games_on_player"
|
||||
t.index ["status"], name: "index_break_escape_games_on_status"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user