Files
BreakEscape/app/controllers/break_escape/games_controller.rb
Z. Cliffe Schreuders f30dd7f279 Update scenario.json.erb: Change NPC sprite and add closing debrief triggers
- Updated spriteSheet for Sarah Martinez from "female_office_worker" to "female_blowse".
- Added new event triggers for closing debrief upon entering the main office area and confronting Derek.
- Modified Agent 0x99 to use a person type NPC with updated event mappings and properties.
2026-02-17 16:33:49 +00:00

1369 lines
51 KiB
Ruby

require 'open3'
module BreakEscape
class GamesController < ApplicationController
helper PlayerPreferencesHelper
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :update_room, :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_player)
@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 VM IPs JSON
if params[:vm_ips_json].present?
begin
vm_ips = JSON.parse(params[:vm_ips_json])
initial_player_state['vm_ips'] = vm_ips if vm_ips.is_a?(Hash)
rescue JSON::ParserError => e
Rails.logger.warn "[BreakEscape] Invalid vm_ips_json: #{e.message}"
end
end
# Standalone mode with XML flag hints
if params[:flag_hints_xml].present?
flags_by_vm = Mission.parse_flag_hints_xml(params[:flag_hints_xml])
initial_player_state['flags_by_vm'] = flags_by_vm
# Also store flat list for backward compatibility
initial_player_state['standalone_flags'] = flags_by_vm.values.flatten.uniq
# Legacy: comma-separated flags (backward compatibility)
elsif 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!
# Check if player's sprite is valid for this scenario
player_pref = current_player_preference || create_default_preference
if !player_pref.sprite_selected?
# No sprite selected - MUST configure
flash[:alert] = 'Please select your character before starting.'
redirect_to configuration_path(game_id: @game.id)
elsif !player_pref.sprite_valid_for_scenario?(@game.scenario_data)
# Sprite selected but invalid for this scenario
flash[:alert] = 'Your selected character is not available for this mission. Please choose another.'
redirect_to configuration_path(game_id: @game.id)
else
# All good - start game
redirect_to game_path(@game)
end
end
def show
authorize @game if defined?(Pundit)
@mission = @game.mission
# Load player preference data for the in-game modal
@player_preference = current_player_preference || create_default_preference
@available_sprites = PlayerPreference::AVAILABLE_SPRITES
# Debug logging
Rails.logger.info "[BreakEscape] Loading game#show for player: #{current_player.class.name}##{current_player.id}"
Rails.logger.info "[BreakEscape] Player preference: #{@player_preference.inspect}"
Rails.logger.info "[BreakEscape] Selected sprite: #{@player_preference.selected_sprite.inspect}"
end
# GET /games/:id/scenario
# Returns filtered scenario JSON for this game instance
# Uses filtered_scenario_for_bootstrap for lazy-loading support
def scenario
authorize @game if defined?(Pundit)
begin
# Use filtered bootstrap scenario and remove 'requires' fields for security
filtered = @game.filtered_scenario_for_bootstrap
# Remove 'requires' fields recursively for security
filter_requires_recursive(filtered)
# Include objectives state for page reload recovery
# This allows the client to restore completed/progress state
if @game.player_state['objectivesState'].present?
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
# Include current inventory from player_state for page reload recovery
# This allows the client to restore inventory state on reload
if @game.player_state['inventory'].present? && @game.player_state['inventory'].is_a?(Array)
filtered['playerInventory'] = @game.player_state['inventory']
# Remove startItemsInInventory from scenario to prevent duplicates
# Since we're sending the actual inventory, we don't need the starting items
filtered.delete('startItemsInInventory')
end
render json: filtered
rescue => e
Rails.logger.error "[BreakEscape] scenario error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
render_error("Failed to generate scenario: #{e.message}", :internal_server_error)
end
end
# GET /games/:id/scenario_map
# Returns minimal scenario metadata for navigation (no room contents)
def scenario_map
authorize @game if defined?(Pundit)
begin
# Check if scenario_data exists
unless @game.scenario_data.present?
Rails.logger.error "[BreakEscape] scenario_map: Game #{@game.id} has no scenario_data"
return render_error('Scenario data not available', :internal_server_error)
end
# Return minimal room/connection metadata without contents
layout = {}
rooms = @game.scenario_data['rooms'] || {}
Rails.logger.debug "[BreakEscape] scenario_map: Processing #{rooms.keys.length rescue 0} rooms"
rooms.each do |room_id, room_data|
next unless room_data.is_a?(Hash)
begin
layout[room_id] = {
type: room_data['type'],
connections: room_data['connections'] || {},
locked: room_data['locked'] || false,
lockType: room_data['lockType'],
hasNPCs: (room_data['npcs']&.length || 0) > 0,
accessible: @game.room_unlocked?(room_id)
}
rescue => e
Rails.logger.error "[BreakEscape] Error processing room #{room_id}: #{e.message}"
# Skip this room and continue
next
end
end
render json: {
startRoom: @game.scenario_data['startRoom'],
currentRoom: @game.player_state['currentRoom'],
rooms: layout
}
rescue => e
Rails.logger.error "[BreakEscape] scenario_map error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
render_error("Failed to generate scenario map: #{e.message}", :internal_server_error)
end
end
# GET /games/:id/room/:room_id
# Returns room data for a specific room (lazy-loading support)
def room
authorize @game if defined?(Pundit)
begin
room_id = params[:room_id]
return render_error('Missing room_id parameter', :bad_request) unless room_id.present?
# Check if scenario_data exists
unless @game.scenario_data.present?
Rails.logger.error "[BreakEscape] room: Game #{@game.id} has no scenario_data"
return render_error('Scenario data not available', :internal_server_error)
end
# Check if room exists in scenario FIRST (before accessibility check)
unless @game.scenario_data['rooms']&.key?(room_id)
return render_error("Room not found: #{room_id}", :not_found)
end
# Check if room is accessible (starting room OR in unlockedRooms)
is_start_room = @game.scenario_data['startRoom'] == room_id
is_unlocked = @game.player_state['unlockedRooms']&.include?(room_id)
unless is_start_room || is_unlocked
return render_error("Room not accessible: #{room_id}", :forbidden)
end
# Auto-add room to unlockedRooms when accessed
# This ensures items in the room can be collected
if !is_unlocked
@game.player_state['unlockedRooms'] ||= []
@game.player_state['unlockedRooms'] << room_id unless @game.player_state['unlockedRooms'].include?(room_id)
@game.save!
Rails.logger.info "[BreakEscape] Auto-unlocked room #{room_id} on access"
end
# Get and filter room data
room_data = @game.filtered_room_data(room_id)
return render_error("Room not found: #{room_id}", :not_found) unless room_data
# Track NPC encounters BEFORE sending response
npc_count = room_data['npcs']&.length || 0
Rails.logger.info "[BreakEscape] 📦 Loading room: #{room_id} (NPCs: #{npc_count})"
track_npc_encounters(room_id, room_data)
Rails.logger.debug "[BreakEscape] Serving room data for: #{room_id}"
render json: { room_id: room_id, room: room_data }
rescue => e
Rails.logger.error "[BreakEscape] room error: #{e.message}\n#{e.backtrace.first(10).join("\n")}"
render_error("Failed to load room: #{e.message}", :internal_server_error)
end
end
# GET /games/:id/container/:container_id
# Returns container contents after unlock (lazy-loaded)
def container
authorize @game if defined?(Pundit)
# Reload game to get latest player_state (in case inventory was updated)
@game.reload
container_id = params[:container_id]
return render_error('Missing container_id parameter', :bad_request) unless container_id.present?
# Find container in scenario data
container_data = find_container_in_scenario(container_id)
return render_error("Container not found: #{container_id}", :not_found) unless container_data
# Check if container is unlocked (check multiple possible identifiers)
is_unlocked = check_container_unlocked(container_id, container_data)
unless is_unlocked
return render_error("Container not unlocked: #{container_id}", :forbidden)
end
# Return filtered contents
contents = filter_container_contents(container_data)
Rails.logger.info "[BreakEscape] Serving container contents for: #{container_id} - returning #{contents.length} items"
Rails.logger.debug "[BreakEscape] Container contents: #{contents.map { |c| "#{c['type']}/#{c['id']}/#{c['name']}" }.join(', ')}"
render json: {
container_id: container_id,
contents: contents
}
end
# GET /games/:id/ink?npc=helper1
# Returns NPC script (JIT compiled if needed)
def ink
authorize @game if defined?(Pundit)
npc_id = params[:npc]
return render_error('Missing npc parameter', :bad_request) unless npc_id.present?
Rails.logger.debug "[BreakEscape] Loading ink for NPC: #{npc_id}"
# Find NPC in scenario data
npc = find_npc_in_scenario(npc_id)
return render_error("NPC not found in scenario: #{npc_id}", :not_found) unless npc
Rails.logger.debug "[BreakEscape] Found NPC: #{npc['id']} with storyPath: #{npc['storyPath']}"
# Check if storyPath is set
unless npc['storyPath'].present?
Rails.logger.warn "[BreakEscape] NPC #{npc['id']} has no storyPath defined"
return render_error("NPC #{npc['id']} has no storyPath defined", :bad_request)
end
# Resolve ink file path and compile if needed
ink_json_path = resolve_and_compile_ink(npc['storyPath'])
unless ink_json_path && File.exist?(ink_json_path)
Rails.logger.error "[BreakEscape] Ink file not found for #{npc['storyPath']} (resolved to #{ink_json_path})"
return render_error("Ink script not found for #{npc['storyPath']}", :not_found)
end
Rails.logger.debug "[BreakEscape] Serving ink from: #{ink_json_path}"
# Serve compiled JSON
render json: JSON.parse(File.read(ink_json_path))
rescue JSON::ParserError => e
render_error("Invalid JSON in compiled ink: #{e.message}", :internal_server_error)
end
# PUT /games/:id/sync_state
# Periodic state sync from client
def sync_state
authorize @game if defined?(Pundit)
# Update allowed fields
if params[:currentRoom]
# Verify room is accessible
if @game.player_state['unlockedRooms'].include?(params[:currentRoom]) ||
@game.scenario_data['startRoom'] == params[:currentRoom]
@game.player_state['currentRoom'] = params[:currentRoom]
else
return render json: {
success: false,
message: "Cannot enter locked room: #{params[:currentRoom]}"
}, status: :forbidden
end
end
if params[:globalVariables]
@game.update_global_variables!(params[:globalVariables].to_unsafe_h)
end
@game.save!
render json: { success: true }
end
# POST /games/:id/update_room
# Update dynamic room state (items added/removed, NPCs moved, object state changes)
def update_room
authorize @game if defined?(Pundit)
room_id = params[:roomId]
action_type = params[:actionType] # Renamed from 'action' to avoid Rails conflict
data = params[:data]
unless room_id.present? && action_type.present?
return render json: { success: false, message: 'Missing roomId or actionType' }, status: :bad_request
end
# Validate room is accessible
unless @game.room_unlocked?(room_id)
return render json: { success: false, message: 'Room not accessible' }, status: :forbidden
end
success = case action_type
when 'add_object'
# Validate item data (data is ActionController::Parameters)
unless data.present? && (data[:type].present? || data['type'].present?)
return render json: { success: false, message: 'Invalid item data' }, status: :bad_request
end
source_data = {
'npc_id' => params[:sourceNpcId],
'source_type' => params[:sourceType]
}.compact
# Use strong parameters instead of to_unsafe_h
@game.add_item_to_room!(room_id, item_add_params, source_data)
when 'remove_object'
item_id = data[:id] || data['id'] || data[:itemId] || data['itemId']
unless item_id.present?
return render json: { success: false, message: 'Missing item id' }, status: :bad_request
end
@game.remove_item_from_room!(room_id, item_id)
when 'update_object_state'
object_id = data[:objectId] || data['objectId']
state_changes = data[:stateChanges] || data['stateChanges']
unless object_id.present? && state_changes.present?
return render json: { success: false, message: 'Invalid object state data' }, status: :bad_request
end
# Use strong parameters instead of to_unsafe_h
@game.update_object_state!(room_id, object_id, object_state_params)
when 'update_npc_state'
npc_id = data[:npcId] || data['npcId']
state_changes = data[:stateChanges] || data['stateChanges']
unless npc_id.present? && state_changes.present?
return render json: { success: false, message: 'Invalid NPC state data' }, status: :bad_request
end
# Use strong parameters instead of to_unsafe_h
@game.update_npc_state!(room_id, npc_id, npc_state_params)
when 'move_npc'
npc_id = data[:npcId] || data['npcId']
from_room = data[:fromRoom] || data['fromRoom']
to_room = data[:toRoom] || data['toRoom']
unless npc_id.present? && from_room.present? && to_room.present?
return render json: { success: false, message: 'Invalid NPC move data' }, status: :bad_request
end
@game.move_npc_to_room!(npc_id, from_room, to_room)
else
return render json: { success: false, message: "Unknown action: #{action_type}" }, status: :bad_request
end
if success
render json: { success: true }
else
render json: { success: false, message: 'Failed to update room state' }, status: :unprocessable_entity
end
end
# POST /games/:id/unlock
# Validate unlock attempt
def unlock
authorize @game if defined?(Pundit)
target_type = params[:targetType]
target_id = params[:targetId]
attempt = params[:attempt]
method = params[:method]
is_valid = @game.validate_unlock(target_type, target_id, attempt, method)
unless is_valid
return render json: {
success: false,
message: 'Invalid attempt'
}, status: :unprocessable_entity
end
# Use transaction to ensure atomic update
ActiveRecord::Base.transaction do
if target_type == 'door'
@game.unlock_room!(target_id)
room_data = @game.filtered_room_data(target_id)
# Track NPC encounters when unlocking a door (room data is cached by client)
# This ensures NPCs are tracked even if loadRoom() uses cached data
track_npc_encounters(target_id, room_data)
render json: {
success: true,
type: 'door',
roomData: room_data
}
else
# Object/container unlock
@game.unlock_object!(target_id)
# Find the unlocked object and return its contents if it's a container
object_data = find_object_in_scenario(target_id)
response = {
success: true,
type: 'object'
}
# If object has contents, include them in response
if object_data && object_data['contents'].present?
response[:hasContents] = true
response[:contents] = object_data['contents']
end
render json: response
end
end
rescue ActiveRecord::RecordInvalid => e
render json: {
success: false,
message: "Failed to save unlock: #{e.message}"
}, status: :unprocessable_entity
end
# POST /games/:id/inventory
# Update inventory
def inventory
authorize @game if defined?(Pundit)
action_type = params[:action_type] || params[:actionType]
item = params[:item]
Rails.logger.info "[BreakEscape] inventory endpoint: action=#{action_type}, item=#{item.inspect}"
begin
case action_type
when 'add'
# Validate item exists and is collectible
validation_error = validate_item_collectible(item)
if validation_error
Rails.logger.warn "[BreakEscape] inventory validation failed: #{validation_error}"
return render json: { success: false, message: validation_error },
status: :unprocessable_entity
end
Rails.logger.info "[BreakEscape] Adding item to inventory: #{item['type']} / #{item['name']}"
@game.add_inventory_item!(item.to_unsafe_h)
Rails.logger.info "[BreakEscape] Item added successfully. Current inventory size: #{@game.player_state['inventory']&.length}"
render json: { success: true, inventory: @game.player_state['inventory'] }
when 'remove'
@game.remove_inventory_item!(item['id'])
render json: { success: true, inventory: @game.player_state['inventory'] }
else
render json: { success: false, message: 'Invalid action' }, status: :bad_request
end
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "[BreakEscape] Inventory save failed: #{e.message}"
render json: { success: false, message: "Failed to save inventory: #{e.message}" },
status: :unprocessable_entity
rescue => e
Rails.logger.error "[BreakEscape] Inventory error: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: { success: false, message: "Inventory error: #{e.message}" },
status: :internal_server_error
end
end
# ==========================================
# Objectives System
# ==========================================
# GET /games/:id/objectives
# Returns current objectives and their state
def objectives
authorize @game if defined?(Pundit)
render json: @game.objectives_state
end
# POST /games/:id/objectives/tasks/:task_id
# Complete a specific task
def complete_task
authorize @game if defined?(Pundit)
task_id = params[:task_id]
unless task_id.present?
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
end
# For submit_flags tasks, accept submittedFlags from request body for validation
validation_data = params[:validation_data] || {}
if params[:submittedFlags].present?
validation_data[:submittedFlags] = params[:submittedFlags]
end
result = @game.complete_task!(task_id, validation_data)
if result[:success]
Rails.logger.info "[BreakEscape] Task completed: #{task_id}"
render json: result
else
Rails.logger.warn "[BreakEscape] Task completion failed: #{task_id} - #{result[:error]}"
render json: result, status: :unprocessable_entity
end
end
# PUT /games/:id/objectives/tasks/:task_id
# Update task progress (for collect_items and submit_flags tasks)
def update_task_progress
authorize @game if defined?(Pundit)
task_id = params[:task_id]
progress = params[:progress].to_i
submitted_flags = params[:submittedFlags]
unless task_id.present?
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
end
result = @game.update_task_progress!(task_id, progress, submitted_flags)
Rails.logger.debug "[BreakEscape] Task progress updated: #{task_id} = #{progress}, submittedFlags: #{submitted_flags&.length || 0}"
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 flag-station and generate flag identifier
flag_station = find_flag_station_for_flag(flag_key)
flag_id = generate_flag_identifier(flag_key, flag_station)
vm_id = flag_station&.dig('acceptsVms', 0)
# 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}, flagId: #{flag_id}, rewards: #{reward_results.length}"
render json: {
success: true,
message: result[:message],
flag: flag_key,
flagId: flag_id,
vmId: vm_id,
rewards: reward_results
}
else
render json: result, status: :unprocessable_entity
end
end
private
def set_game
@game = Game.find(params[:id])
end
def filter_requires_recursive(obj)
case obj
when Hash
# Remove 'requires' for exploitable lock types (key/pin/password)
# Keep it for biometric/bluetooth/rfid since they reference collectible items, not answers
# - biometric: requires fingerprint owner name (e.g., "Mrs Moo")
# - bluetooth: requires device MAC/name (e.g., "00:11:22:33:44:55")
# - rfid: requires card IDs (e.g., ["master_keycard"])
lock_type = obj['lockType']
if lock_type && !%w[biometric bluetooth rfid].include?(lock_type)
obj.delete('requires')
end
# Recursively filter nested structures
obj.each_value { |value| filter_requires_recursive(value) }
when Array
obj.each { |item| filter_requires_recursive(item) }
end
end
def track_npc_encounters(room_id, room_data)
return unless room_data['npcs'].present?
begin
npc_ids = room_data['npcs'].map { |npc| npc['id'] }
# Ensure player_state is a hash
unless @game.player_state.is_a?(Hash)
Rails.logger.error "[BreakEscape] player_state is not a Hash: #{@game.player_state.class}"
@game.player_state = {}
end
# Ensure encounteredNPCs is an array
@game.player_state['encounteredNPCs'] ||= []
# Handle case where encounteredNPCs might not be an array (legacy data)
unless @game.player_state['encounteredNPCs'].is_a?(Array)
Rails.logger.warn "[BreakEscape] encounteredNPCs is not an Array: #{@game.player_state['encounteredNPCs'].class}, resetting"
@game.player_state['encounteredNPCs'] = []
end
new_npcs = npc_ids - @game.player_state['encounteredNPCs']
return if new_npcs.empty?
# Log detailed information about each new NPC encountered
new_npcs.each do |npc_id|
npc_data = room_data['npcs'].find { |npc| npc['id'] == npc_id }
if npc_data
display_name = npc_data['displayName'] || npc_id
npc_type = npc_data['npcType'] || 'unknown'
Rails.logger.info "[BreakEscape] 🎭 NPC ENCOUNTERED: #{display_name} (#{npc_id}) - Type: #{npc_type} - Room: #{room_id}"
else
Rails.logger.info "[BreakEscape] 🎭 NPC ENCOUNTERED: #{npc_id} - Room: #{room_id}"
end
end
@game.player_state['encounteredNPCs'] = (@game.player_state['encounteredNPCs'] + new_npcs).uniq
@game.save!
total_encountered = @game.player_state['encounteredNPCs'].length
Rails.logger.info "[BreakEscape] ✅ Tracked #{new_npcs.length} new NPC encounter(s) in room #{room_id}. Total NPCs encountered: #{total_encountered}"
rescue => e
Rails.logger.error "[BreakEscape] Error tracking NPC encounters: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
# Continue without tracking to avoid breaking room loading
end
end
def find_container_in_scenario(container_id)
@game.scenario_data['rooms'].each do |room_id, room_data|
# Search objects for container
container = find_container_recursive(room_data['objects'], container_id)
return container if container
# Search nested contents
room_data['objects']&.each do |obj|
container = search_nested_contents(obj['contents'], container_id)
return container if container
end
end
nil
end
def find_container_recursive(objects, container_id)
return nil unless objects
objects.each do |obj|
# Check if this object matches
if obj['id'] == container_id || (obj['name'] && obj['name'] == container_id)
return obj if obj['contents'].present?
end
# Recursively search nested contents
nested = find_container_recursive(obj['contents'], container_id)
return nested if nested
end
nil
end
def find_object_in_scenario(object_id)
# Search all rooms for the object
@game.scenario_data['rooms'].each do |_room_id, room_data|
object = room_data['objects']&.find { |obj|
obj['id'] == object_id || obj['name'] == object_id
}
return object if object
end
nil
end
def search_nested_contents(contents, container_id)
return nil unless contents
contents.each do |item|
return item if (item['id'] == container_id || item['name'] == container_id) && item['contents'].present?
nested = search_nested_contents(item['contents'], container_id)
return nested if nested
end
nil
end
def check_container_unlocked(container_id, container_data)
unlocked_list = @game.player_state['unlockedObjects'] || []
# Check multiple possible identifiers
unlocked_list.include?(container_id) ||
unlocked_list.include?(container_data['id']) ||
unlocked_list.include?(container_data['name']) ||
unlocked_list.include?(container_data['type'])
end
def filter_container_contents(container_data)
contents = container_data['contents']&.map do |item|
item_copy = item.deep_dup
@game.send(:filter_requires_and_contents_recursive, item_copy)
item_copy
end || []
# Filter out items that are already in the player's inventory
inventory = @game.player_state['inventory'] || []
Rails.logger.debug "[BreakEscape] Filtering container contents. Inventory has #{inventory.length} items"
Rails.logger.debug "[BreakEscape] Container has #{contents.length} items before filtering"
filtered_contents = contents.reject do |item|
in_inventory = item_in_inventory?(item, inventory)
if in_inventory
Rails.logger.debug "[BreakEscape] Filtering out item: #{item['type']} / #{item['id']} / #{item['name']} (already in inventory)"
end
in_inventory
end
Rails.logger.debug "[BreakEscape] Container has #{filtered_contents.length} items after filtering"
filtered_contents
end
# Check if an item is already in the player's inventory
# Matches by type, id, or name (similar to validation logic)
def item_in_inventory?(item, inventory)
return false if inventory.blank? || item.blank?
# Normalize item data (handle both string and symbol keys)
item_type = item['type'] || item[:type]
item_id = item['key_id'] || item[:key_id] || item['id'] || item[:id]
item_name = item['name'] || item[:name]
Rails.logger.debug "[BreakEscape] Checking if item in inventory: type=#{item_type}, id=#{item_id}, name=#{item_name}"
inventory.any? do |inv_item|
# Inventory items are stored as flat objects (not nested in scenarioData)
# Handle both string and symbol keys
inv_type = inv_item['type'] || inv_item[:type]
inv_id = inv_item['key_id'] || inv_item[:key_id] || inv_item['id'] || inv_item[:id]
inv_name = inv_item['name'] || inv_item[:name]
Rails.logger.debug "[BreakEscape] Comparing with inventory item: type=#{inv_type}, id=#{inv_id}, name=#{inv_name}"
# Must match type
next false unless inv_type == item_type
# If both have IDs, match by ID (most specific)
if item_id.present? && inv_id.present?
match = inv_id.to_s == item_id.to_s
Rails.logger.debug "[BreakEscape] ID match: #{match} (#{item_id} == #{inv_id})"
return true if match
end
# If both have names, match by name (fallback if no ID match)
if item_name.present? && inv_name.present?
match = inv_name.to_s == item_name.to_s
Rails.logger.debug "[BreakEscape] Name match: #{match} (#{item_name} == #{inv_name})"
return true if match
end
# If item has no ID or name, match by type only (less specific, but works for generic items)
if item_id.blank? && item_name.blank?
Rails.logger.debug "[BreakEscape] Type-only match (no ID/name)"
return true
end
false
end
end
# Items that are always allowed in inventory (core game mechanics)
ALWAYS_ALLOWED_ITEMS = %w[notepad].freeze
def validate_item_collectible(item)
item_type = item['type']
# Use key_id for keys (more unique), fall back to id for other items
item_id = item['key_id'] || item['id']
item_name = item['name']
Rails.logger.info "[BreakEscape] validate_item_collectible: type=#{item_type}, id=#{item_id}, name=#{item_name}"
# Always allow core game items like notepad
if ALWAYS_ALLOWED_ITEMS.include?(item_type)
Rails.logger.info "[BreakEscape] Item is always allowed: #{item_type}"
return nil
end
# Check if this is a starting item first (if so, skip all other checks)
is_starting_item = @game.scenario_data['startItemsInInventory']&.any? do |start_item|
start_item['type'] == item_type && (start_item['id'] == item_id || start_item['name'] == item_name)
end
if is_starting_item
Rails.logger.info "[BreakEscape] Item is a starting item, skipping room/container checks"
return nil # Starting items are always valid
end
# Search for item, prioritizing accessible locations (not locked containers/rooms)
found_item_info = find_accessible_item(item_type, item_id, item_name)
unless found_item_info
error_msg = "Item not found in scenario: #{item_type}"
Rails.logger.warn "[BreakEscape] #{error_msg}"
return error_msg
end
found_item = found_item_info[:item]
location = found_item_info[:location]
# Check if item is takeable
unless found_item['takeable']
error_msg = "Item is not takeable: #{found_item['name']}"
Rails.logger.warn "[BreakEscape] #{error_msg}"
return error_msg
end
# Check access based on location type
if location[:type] == 'container'
container_id = location[:container_id]
unless @game.player_state['unlockedObjects'].include?(container_id)
error_msg = "Container not unlocked: #{container_id}"
Rails.logger.warn "[BreakEscape] #{error_msg}"
return error_msg
end
elsif location[:type] == 'room'
room_id = location[:room_id]
room_info = @game.scenario_data['rooms'][room_id]
if room_info && room_info['locked'] && !@game.player_state['unlockedRooms'].include?(room_id)
error_msg = "Room not unlocked: #{room_id}"
Rails.logger.warn "[BreakEscape] #{error_msg}"
return error_msg
end
elsif location[:type] == 'npc'
npc_id = location[:npc_id]
unless @game.player_state['encounteredNPCs'].include?(npc_id)
error_msg = "NPC not encountered: #{npc_id}"
Rails.logger.warn "[BreakEscape] #{error_msg}"
return error_msg
end
elsif location[:type] == 'flag_station'
# Flag station items are valid if they're in the player's inventory (already awarded server-side)
# or if the corresponding flag has been submitted
flag_station_id = location[:flag_station_id]
Rails.logger.info "[BreakEscape] Item from flag station #{flag_station_id}, allowing (flag reward)"
# Flag rewards are always valid - the server already validated and added them
end
Rails.logger.info "[BreakEscape] Item collection valid: #{item_type}"
nil # No error
end
def find_accessible_item(item_type, item_id, item_name)
# Priority 1: Items in unlocked rooms (most accessible)
@game.scenario_data['rooms'].each do |room_id, room_data|
if room_data['locked'] == false || @game.player_state['unlockedRooms'].include?(room_id)
room_data['objects']&.each do |obj|
if obj['type'] == item_type && (obj['key_id'] == item_id || obj['id'] == item_id || obj['name'] == item_name || obj['name'] == item_id)
return { item: obj, location: { type: 'room', room_id: room_id } }
end
end
end
end
# Priority 2: Items in any room (including locked ones - will validate in main method)
@game.scenario_data['rooms'].each do |room_id, room_data|
room_data['objects']&.each do |obj|
if obj['type'] == item_type && (obj['key_id'] == item_id || obj['id'] == item_id || obj['name'] == item_name || obj['name'] == item_id)
return { item: obj, location: { type: 'room', room_id: room_id } }
end
# Search nested contents in room objects
obj['contents']&.each do |content|
if content['type'] == item_type && (content['key_id'] == item_id || content['id'] == item_id || content['name'] == item_name || content['name'] == item_id)
return { item: content, location: { type: 'container', container_id: obj['id'] || obj['name'] } }
end
end
# Search flag-station itemsHeld (flag reward items)
if obj['type'] == 'flag-station' && obj['itemsHeld'].present?
obj['itemsHeld'].each do |held_item|
if held_item['type'] == item_type && (held_item['key_id'] == item_id || held_item['keyId'] == item_id || held_item['id'] == item_id || held_item['name'] == item_name || held_item['name'] == item_id)
return { item: held_item, location: { type: 'flag_station', flag_station_id: obj['id'] || obj['name'], room_id: room_id } }
end
end
end
end
# Priority 3: Items held by NPCs in this room
room_data['npcs']&.each do |npc|
next unless npc['itemsHeld'].present?
npc['itemsHeld'].each do |held_item|
if held_item['type'] == item_type && (held_item['key_id'] == item_id || held_item['id'] == item_id || held_item['name'] == item_name || held_item['name'] == item_id)
return { item: held_item, location: { type: 'npc', npc_id: npc['id'], room_id: room_id } }
end
end
end
end
nil
end
def find_item_in_scenario(item_type, item_id, item_name = nil)
# First check startItemsInInventory (items the player begins with)
@game.scenario_data['startItemsInInventory']&.each do |item|
if item['type'] == item_type && (item['key_id'] == item_id || item['id'] == item_id || item['name'] == item_name || item['name'] == item_id)
return item
end
end
# Then search room objects
@game.scenario_data['rooms'].each do |room_id, room_data|
# Search room objects
room_data['objects']&.each do |obj|
if obj['type'] == item_type && (obj['key_id'] == item_id || obj['id'] == item_id || obj['name'] == item_name || obj['name'] == item_id)
return obj
end
# Search nested contents
obj['contents']&.each do |content|
if content['type'] == item_type && (content['key_id'] == item_id || content['id'] == item_id || content['name'] == item_name || content['name'] == item_id)
return content
end
end
end
end
nil
end
def find_item_container(item_type, item_id)
@game.scenario_data['rooms'].each do |room_id, room_data|
room_data['objects']&.each do |obj|
obj['contents']&.each do |content|
if content['type'] == item_type && (content['id'] == item_id || content['name'] == item_id)
return { id: obj['id'] || obj['name'], locked: obj['locked'] }
end
end
end
end
nil
end
def find_item_room(item_type, item_id)
@game.scenario_data['rooms'].each do |room_id, room_data|
room_data['objects']&.each do |obj|
if obj['type'] == item_type && (obj['id'] == item_id || obj['name'] == item_id)
return { id: room_id, locked: room_data['locked'] }
end
end
end
nil
end
def find_npc_holding_item(item_type, item_id)
@game.scenario_data['rooms'].each do |room_id, room_data|
room_data['npcs']&.each do |npc|
next unless npc['itemsHeld'].present?
# itemsHeld is array of full item objects (same structure as room objects)
npc['itemsHeld'].each do |held_item|
# Match by type (required) and optionally by id/name
if held_item['type'] == item_type
# If item_id provided, verify it matches
if item_id.present?
item_matches = (held_item['id'] == item_id) ||
(held_item['name'] == item_id) ||
(item_id == item_type) # Fallback if no id field
next unless item_matches
end
return {
id: npc['id'],
npc: npc,
item: held_item,
type: 'npc'
}
end
end
end
end
nil
end
def find_npc_in_scenario(npc_id)
available_npcs = []
@game.scenario_data['rooms']&.each do |room_id, room_data|
room_data['npcs']&.each do |npc|
available_npcs << "#{npc['id']} (#{room_id})"
return npc if npc['id'] == npc_id
end
end
# Log available NPCs for debugging
if available_npcs.any?
Rails.logger.debug "[BreakEscape] Available NPCs: #{available_npcs.join(', ')}"
else
Rails.logger.warn "[BreakEscape] No NPCs found in scenario data"
end
nil
end
# Resolve ink path and compile if necessary
def resolve_and_compile_ink(story_path)
# Use Engine root for Rails Engine context
engine_root = BreakEscape::Engine.root
base_path = engine_root.join(story_path)
json_path = find_compiled_json(base_path)
ink_path = find_ink_source(base_path)
if ink_path && needs_compilation?(ink_path, json_path)
Rails.logger.info "[BreakEscape] Compiling #{File.basename(ink_path)}..."
json_path = compile_ink(ink_path)
end
json_path
end
def find_compiled_json(base_path)
return base_path if File.exist?(base_path)
ink_json_path = base_path.to_s.gsub(/\.json$/, '.ink.json')
return Pathname.new(ink_json_path) if File.exist?(ink_json_path)
json_path = base_path.to_s.gsub(/\.ink\.json$/, '.json')
return Pathname.new(json_path) if File.exist?(json_path)
nil
end
def find_ink_source(base_path)
ink_path = base_path.to_s.gsub(/\.(ink\.)?json$/, '.ink')
File.exist?(ink_path) ? Pathname.new(ink_path) : nil
end
def needs_compilation?(ink_path, json_path)
return true unless json_path && File.exist?(json_path)
File.mtime(ink_path) > File.mtime(json_path)
end
def compile_ink(ink_path)
output_path = ink_path.to_s.gsub(/\.ink$/, '.json')
inklecate_path = BreakEscape::Engine.root.join('bin', 'inklecate')
stdout, stderr, status = Open3.capture3(
inklecate_path.to_s,
'-jo', output_path,
ink_path.to_s
)
unless status.success?
Rails.logger.error "[BreakEscape] Ink compilation failed: #{stderr}"
raise "Ink compilation failed for #{File.basename(ink_path)}: #{stderr}"
end
if stderr.present?
Rails.logger.warn "[BreakEscape] Ink compilation warnings: #{stderr}"
end
Rails.logger.info "[BreakEscape] Compiled #{File.basename(ink_path)} (#{(File.size(output_path) / 1024.0).round(2)} KB)"
Pathname.new(output_path)
end
def render_error(message, status)
render json: { error: message }, status: status
end
# Strong parameters for room state sync (SECURITY)
def item_add_params
# Allow common item properties, including nested scenarioData
params.require(:data).permit(
:id, :type, :name, :texture, :x, :y, :takeable, :interactable,
scenarioData: [
:type, :name, :takeable, :key_id, :observations, :active, :visible, :interactable,
keyPins: []
]
).to_h
end
def object_state_params
# Only allow safe state changes (not 'locked' which bypasses puzzles)
params.require(:data).require(:stateChanges).permit(
:opened, :on, :brightness, :screen_state
).to_h
end
def npc_state_params
# Only allow KO status and HP changes (validated further in model)
params.require(:data).require(:stateChanges).permit(
:isKO, :currentHP
).to_h
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
# Find the flag-station that contains the submitted flag
def find_flag_station_for_flag(flag_key)
@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 }
return obj
end
end
nil
end
# Generate a flag identifier in the format: {vmId}-flag{index}
# Example: "desktop-flag1", "kali-flag2"
def generate_flag_identifier(flag_key, flag_station)
return nil unless flag_station
# Find flag index in flags array (0-based)
flag_index = flag_station['flags']&.find_index { |f| f.downcase == flag_key.downcase }
return nil unless flag_index
# Get VM ID (use first VM if multiple)
vm_id = flag_station['acceptsVms']&.first
return nil unless vm_id
# Generate identifier: "desktop-flag1" (1-indexed for display)
"#{vm_id}-flag#{flag_index + 1}"
end
# Get current player's preference record
def current_player_preference
if current_player.respond_to?(:break_escape_preference)
current_player.break_escape_preference
elsif current_player.respond_to?(:preference)
# Reload association to ensure fresh data
current_player.reload.preference
end
end
# Create default preference for player
def create_default_preference
if current_player.respond_to?(:ensure_break_escape_preference!)
current_player.ensure_break_escape_preference!
current_player.break_escape_preference
elsif current_player.respond_to?(:ensure_preference!)
current_player.ensure_preference!
current_player.preference
else
# Fallback: create directly
PlayerPreference.create!(player: current_player)
end
end
end
end