WiP implementing VM integration

This commit is contained in:
Z. Cliffe Schreuders
2025-11-28 15:36:10 +00:00
parent 3a64ffe8f1
commit ea079b11c9
20 changed files with 2265 additions and 21 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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?

View 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>

View File

@@ -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

View File

@@ -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

View 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

View 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;
}

View 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;
}

View File

@@ -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) {

View File

@@ -54,7 +54,8 @@ window.gameState = {
biometricUnlocks: [],
bluetoothDevices: [],
notes: [],
startTime: null
startTime: null,
submittedFlags: [] // CTF flags that have been submitted
};
window.lastBluetoothScan = 0;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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

View File

@@ -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