2025-11-21 15:27:54 +00:00
|
|
|
require 'open3'
|
|
|
|
|
|
|
|
|
|
module BreakEscape
|
|
|
|
|
class GamesController < ApplicationController
|
2025-11-28 15:36:10 +00:00
|
|
|
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?
|
2025-11-28 17:41:17 +00:00
|
|
|
@available_vm_sets = @mission.valid_vm_sets_for_user(current_player)
|
2025-11-28 15:36:10 +00:00
|
|
|
@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
|
|
|
|
|
|
2025-11-30 00:02:08 +00:00
|
|
|
# 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
|
|
|
|
|
|
2025-11-29 20:57:39 +00:00
|
|
|
# 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?
|
2025-11-28 15:36:10 +00:00
|
|
|
flags = if params[:standalone_flags].is_a?(Array)
|
|
|
|
|
params[:standalone_flags]
|
2025-11-30 00:06:54 +00:00
|
|
|
else
|
2025-11-28 15:36:10 +00:00
|
|
|
params[:standalone_flags].split(',').map(&:strip).reject(&:blank?)
|
2025-11-30 00:06:54 +00:00
|
|
|
end
|
2025-11-28 15:36:10 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
|
|
|
|
|
def show
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
@mission = @game.mission
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# GET /games/:id/scenario
|
2025-11-22 00:46:55 +00:00
|
|
|
# Returns filtered scenario JSON for this game instance
|
|
|
|
|
# Uses filtered_scenario_for_bootstrap for lazy-loading support
|
2025-11-21 15:27:54 +00:00
|
|
|
def scenario
|
|
|
|
|
authorize @game if defined?(Pundit)
|
2025-11-22 00:46:55 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2025-11-25 23:19:11 +00:00
|
|
|
# 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
|
|
|
|
|
|
2025-11-28 15:36:10 +00:00
|
|
|
# Include submitted flags for flag station minigame
|
|
|
|
|
if @game.player_state['submitted_flags'].present?
|
|
|
|
|
filtered['submittedFlags'] = @game.player_state['submitted_flags']
|
|
|
|
|
end
|
|
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
# 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']
|
2026-02-10 16:17:36 +00:00
|
|
|
|
|
|
|
|
# Remove startItemsInInventory from scenario to prevent duplicates
|
|
|
|
|
# Since we're sending the actual inventory, we don't need the starting items
|
|
|
|
|
filtered.delete('startItemsInInventory')
|
2025-12-04 15:42:01 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# GET /games/:id/scenario_map
|
|
|
|
|
# Returns minimal scenario metadata for navigation (no room contents)
|
|
|
|
|
def scenario_map
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
}
|
2025-11-22 00:46:55 +00:00
|
|
|
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)
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
# GET /games/:id/room/:room_id
|
|
|
|
|
# Returns room data for a specific room (lazy-loading support)
|
|
|
|
|
def room
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
begin
|
|
|
|
|
room_id = params[:room_id]
|
|
|
|
|
return render_error('Missing room_id parameter', :bad_request) unless room_id.present?
|
2025-11-21 15:27:54 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-25 16:28:18 +00:00
|
|
|
# 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
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# 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)
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
unless is_start_room || is_unlocked
|
|
|
|
|
return render_error("Room not accessible: #{room_id}", :forbidden)
|
|
|
|
|
end
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
# Auto-add room to unlockedRooms when accessed
|
|
|
|
|
# This ensures items in the room can be collected
|
|
|
|
|
if !is_unlocked
|
2025-11-22 00:46:55 +00:00
|
|
|
@game.player_state['unlockedRooms'] ||= []
|
2025-11-24 11:17:39 +00:00
|
|
|
@game.player_state['unlockedRooms'] << room_id unless @game.player_state['unlockedRooms'].include?(room_id)
|
2025-11-22 00:46:55 +00:00
|
|
|
@game.save!
|
2025-11-24 11:17:39 +00:00
|
|
|
Rails.logger.info "[BreakEscape] Auto-unlocked room #{room_id} on access"
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# Track NPC encounters BEFORE sending response
|
2025-12-01 17:42:28 +00:00
|
|
|
npc_count = room_data['npcs']&.length || 0
|
|
|
|
|
Rails.logger.info "[BreakEscape] 📦 Loading room: #{room_id} (NPCs: #{npc_count})"
|
2025-11-22 00:46:55 +00:00
|
|
|
track_npc_encounters(room_id, room_data)
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
Rails.logger.debug "[BreakEscape] Serving room data for: #{room_id}"
|
2025-11-21 15:27:54 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# GET /games/:id/container/:container_id
|
|
|
|
|
# Returns container contents after unlock (lazy-loaded)
|
|
|
|
|
def container
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
# Reload game to get latest player_state (in case inventory was updated)
|
|
|
|
|
@game.reload
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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)
|
|
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
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(', ')}"
|
2025-11-22 00:46:55 +00:00
|
|
|
|
|
|
|
|
render json: {
|
|
|
|
|
container_id: container_id,
|
|
|
|
|
contents: contents
|
|
|
|
|
}
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
# 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?
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
Rails.logger.debug "[BreakEscape] Loading ink for NPC: #{npc_id}"
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
# Find NPC in scenario data
|
|
|
|
|
npc = find_npc_in_scenario(npc_id)
|
2025-11-21 15:27:54 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
|
|
|
|
|
# Resolve ink file path and compile if needed
|
|
|
|
|
ink_json_path = resolve_and_compile_ink(npc['storyPath'])
|
2025-11-21 15:27:54 +00:00
|
|
|
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}"
|
2025-11-21 15:27:54 +00:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
# PUT /games/:id/sync_state
|
|
|
|
|
# Periodic state sync from client
|
|
|
|
|
def sync_state
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
|
|
|
|
# Update allowed fields
|
|
|
|
|
if params[:currentRoom]
|
2025-11-22 00:46:55 +00:00
|
|
|
# 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
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if params[:globalVariables]
|
|
|
|
|
@game.update_global_variables!(params[:globalVariables].to_unsafe_h)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
@game.save!
|
|
|
|
|
|
|
|
|
|
render json: { success: true }
|
|
|
|
|
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)
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-21 15:27:54 +00:00
|
|
|
if target_type == 'door'
|
|
|
|
|
@game.unlock_room!(target_id)
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
room_data = @game.filtered_room_data(target_id)
|
|
|
|
|
|
2025-12-01 17:42:28 +00:00
|
|
|
# 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)
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
render json: {
|
|
|
|
|
success: true,
|
|
|
|
|
type: 'door',
|
|
|
|
|
roomData: room_data
|
|
|
|
|
}
|
|
|
|
|
else
|
2025-11-22 00:46:55 +00:00
|
|
|
# Object/container unlock
|
2025-11-21 15:27:54 +00:00
|
|
|
@game.unlock_object!(target_id)
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:56 +00:00
|
|
|
# Find the unlocked object and return its contents if it's a container
|
|
|
|
|
object_data = find_object_in_scenario(target_id)
|
|
|
|
|
response = {
|
2025-11-21 15:27:54 +00:00
|
|
|
success: true,
|
|
|
|
|
type: 'object'
|
|
|
|
|
}
|
2025-11-22 00:46:56 +00:00
|
|
|
|
|
|
|
|
# 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
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
end
|
2025-11-22 00:46:55 +00:00
|
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
|
|
|
render json: {
|
|
|
|
|
success: false,
|
|
|
|
|
message: "Failed to save unlock: #{e.message}"
|
|
|
|
|
}, status: :unprocessable_entity
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# POST /games/:id/inventory
|
|
|
|
|
# Update inventory
|
|
|
|
|
def inventory
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
|
|
|
|
action_type = params[:action_type] || params[:actionType]
|
|
|
|
|
item = params[:item]
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
Rails.logger.info "[BreakEscape] inventory endpoint: action=#{action_type}, item=#{item.inspect}"
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
case action_type
|
|
|
|
|
when 'add'
|
2025-11-22 00:46:55 +00:00
|
|
|
# Validate item exists and is collectible
|
|
|
|
|
validation_error = validate_item_collectible(item)
|
|
|
|
|
if validation_error
|
2025-11-24 11:17:39 +00:00
|
|
|
Rails.logger.warn "[BreakEscape] inventory validation failed: #{validation_error}"
|
2025-11-22 00:46:55 +00:00
|
|
|
return render json: { success: false, message: validation_error },
|
|
|
|
|
status: :unprocessable_entity
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
Rails.logger.info "[BreakEscape] Adding item to inventory: #{item['type']} / #{item['name']}"
|
2025-11-21 15:27:54 +00:00
|
|
|
@game.add_inventory_item!(item.to_unsafe_h)
|
2025-11-24 11:17:39 +00:00
|
|
|
Rails.logger.info "[BreakEscape] Item added successfully. Current inventory: #{@game.player_state['inventory'].inspect}"
|
2025-11-21 15:27:54 +00:00
|
|
|
render json: { success: true, inventory: @game.player_state['inventory'] }
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
when 'remove'
|
|
|
|
|
@game.remove_inventory_item!(item['id'])
|
|
|
|
|
render json: { success: true, inventory: @game.player_state['inventory'] }
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
else
|
|
|
|
|
render json: { success: false, message: 'Invalid action' }, status: :bad_request
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-26 00:50:32 +00:00
|
|
|
# ==========================================
|
|
|
|
|
# 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]
|
2025-11-30 00:06:54 +00:00
|
|
|
|
2025-11-26 00:50:32 +00:00
|
|
|
unless task_id.present?
|
|
|
|
|
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
|
|
|
|
|
end
|
|
|
|
|
|
2025-12-04 14:00:26 +00:00
|
|
|
# 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)
|
2025-11-26 00:50:32 +00:00
|
|
|
|
|
|
|
|
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
|
2025-12-04 14:00:26 +00:00
|
|
|
# Update task progress (for collect_items and submit_flags tasks)
|
2025-11-26 00:50:32 +00:00
|
|
|
def update_task_progress
|
|
|
|
|
authorize @game if defined?(Pundit)
|
|
|
|
|
|
|
|
|
|
task_id = params[:task_id]
|
|
|
|
|
progress = params[:progress].to_i
|
2025-12-04 14:00:26 +00:00
|
|
|
submitted_flags = params[:submittedFlags]
|
2025-11-26 00:50:32 +00:00
|
|
|
|
|
|
|
|
unless task_id.present?
|
|
|
|
|
return render json: { success: false, error: 'Missing task_id' }, status: :bad_request
|
|
|
|
|
end
|
|
|
|
|
|
2025-12-04 14:00:26 +00:00
|
|
|
result = @game.update_task_progress!(task_id, progress, submitted_flags)
|
2025-11-30 00:06:54 +00:00
|
|
|
|
2025-12-04 14:00:26 +00:00
|
|
|
Rails.logger.debug "[BreakEscape] Task progress updated: #{task_id} = #{progress}, submittedFlags: #{submitted_flags&.length || 0}"
|
2025-11-26 00:50:32 +00:00
|
|
|
render json: result
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-28 15:36:10 +00:00
|
|
|
# ==========================================
|
|
|
|
|
# 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]
|
2025-12-04 14:00:26 +00:00
|
|
|
# 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)
|
|
|
|
|
|
2025-11-28 15:36:10 +00:00
|
|
|
# Find rewards for this flag in scenario
|
|
|
|
|
rewards = find_flag_rewards(flag_key)
|
|
|
|
|
|
|
|
|
|
# Process rewards
|
|
|
|
|
reward_results = process_flag_rewards(flag_key, rewards)
|
|
|
|
|
|
2025-12-04 14:00:26 +00:00
|
|
|
Rails.logger.info "[BreakEscape] Flag submitted: #{flag_key}, flagId: #{flag_id}, rewards: #{reward_results.length}"
|
2025-11-28 15:36:10 +00:00
|
|
|
|
|
|
|
|
render json: {
|
|
|
|
|
success: true,
|
|
|
|
|
message: result[:message],
|
|
|
|
|
flag: flag_key,
|
2025-12-04 14:00:26 +00:00
|
|
|
flagId: flag_id,
|
|
|
|
|
vmId: vm_id,
|
2025-11-28 15:36:10 +00:00
|
|
|
rewards: reward_results
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
render json: result, status: :unprocessable_entity
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def set_game
|
|
|
|
|
@game = Game.find(params[:id])
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
def filter_requires_recursive(obj)
|
|
|
|
|
case obj
|
|
|
|
|
when Hash
|
2025-11-29 23:43:30 +00:00
|
|
|
# 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"])
|
2025-11-22 00:46:55 +00:00
|
|
|
lock_type = obj['lockType']
|
2025-11-29 23:43:30 +00:00
|
|
|
if lock_type && !%w[biometric bluetooth rfid].include?(lock_type)
|
2025-11-22 00:46:55 +00:00
|
|
|
obj.delete('requires')
|
|
|
|
|
end
|
2025-11-22 00:46:55 +00:00
|
|
|
|
|
|
|
|
# Recursively filter nested structures
|
|
|
|
|
obj.each_value { |value| filter_requires_recursive(value) }
|
|
|
|
|
when Array
|
|
|
|
|
obj.each { |item| filter_requires_recursive(item) }
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
def track_npc_encounters(room_id, room_data)
|
|
|
|
|
return unless room_data['npcs'].present?
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# Ensure encounteredNPCs is an array
|
|
|
|
|
@game.player_state['encounteredNPCs'] ||= []
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
new_npcs = npc_ids - @game.player_state['encounteredNPCs']
|
|
|
|
|
return if new_npcs.empty?
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-12-01 17:31:12 +00:00
|
|
|
# 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
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
@game.player_state['encounteredNPCs'] = (@game.player_state['encounteredNPCs'] + new_npcs).uniq
|
|
|
|
|
@game.save!
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-12-01 17:31:12 +00:00
|
|
|
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}"
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
|
|
|
|
|
2025-11-22 00:46:56 +00:00
|
|
|
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
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
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 || []
|
|
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
# 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"
|
2025-12-04 23:17:34 +00:00
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
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?
|
2025-12-04 23:17:34 +00:00
|
|
|
|
2025-12-04 15:42:01 +00:00
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-26 11:18:25 +00:00
|
|
|
# Items that are always allowed in inventory (core game mechanics)
|
|
|
|
|
ALWAYS_ALLOWED_ITEMS = %w[notepad].freeze
|
|
|
|
|
|
2025-11-22 00:46:55 +00:00
|
|
|
def validate_item_collectible(item)
|
|
|
|
|
item_type = item['type']
|
2025-11-24 11:17:39 +00:00
|
|
|
# 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}"
|
|
|
|
|
|
2025-11-26 11:18:25 +00:00
|
|
|
# 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
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
found_item = found_item_info[:item]
|
|
|
|
|
location = found_item_info[:location]
|
2025-11-22 00:46:55 +00:00
|
|
|
|
|
|
|
|
# Check if item is takeable
|
|
|
|
|
unless found_item['takeable']
|
2025-11-24 11:17:39 +00:00
|
|
|
error_msg = "Item is not takeable: #{found_item['name']}"
|
|
|
|
|
Rails.logger.warn "[BreakEscape] #{error_msg}"
|
|
|
|
|
return error_msg
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
# Check access based on location type
|
|
|
|
|
if location[:type] == 'container'
|
|
|
|
|
container_id = location[:container_id]
|
2025-11-22 00:46:55 +00:00
|
|
|
unless @game.player_state['unlockedObjects'].include?(container_id)
|
2025-11-24 11:17:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
2025-11-29 20:57:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
# 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
|
2025-11-29 20:57:39 +00:00
|
|
|
|
|
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
2025-12-01 17:31:12 +00:00
|
|
|
|
|
|
|
|
# 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
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
nil
|
2025-11-22 00:46:55 +00:00
|
|
|
end
|
|
|
|
|
|
2025-11-24 11:17:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
@game.scenario_data['rooms'].each do |room_id, room_data|
|
|
|
|
|
# Search room objects
|
|
|
|
|
room_data['objects']&.each do |obj|
|
2025-11-24 11:17:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
|
|
|
|
|
# Search nested contents
|
|
|
|
|
obj['contents']&.each do |content|
|
2025-11-24 11:17:39 +00:00
|
|
|
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
|
2025-11-22 00:46:55 +00:00
|
|
|
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
|
|
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
def find_npc_in_scenario(npc_id)
|
2025-11-21 15:27:54 +00:00
|
|
|
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
|
2025-11-25 16:28:18 +00:00
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
# 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"
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
2025-11-25 16:28:18 +00:00
|
|
|
|
2025-11-21 15:27:54 +00:00
|
|
|
nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Resolve ink path and compile if necessary
|
|
|
|
|
def resolve_and_compile_ink(story_path)
|
2025-11-21 15:27:54 +00:00
|
|
|
# Use Engine root for Rails Engine context
|
|
|
|
|
engine_root = BreakEscape::Engine.root
|
|
|
|
|
base_path = engine_root.join(story_path)
|
2025-11-21 15:27:54 +00:00
|
|
|
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')
|
2025-11-26 11:18:25 +00:00
|
|
|
inklecate_path = BreakEscape::Engine.root.join('bin', 'inklecate')
|
2025-11-21 15:27:54 +00:00
|
|
|
|
|
|
|
|
stdout, stderr, status = Open3.capture3(
|
|
|
|
|
inklecate_path.to_s,
|
|
|
|
|
'-o', 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
|
2025-11-28 15:36:10 +00:00
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
# 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
|
2025-12-04 14:00:26 +00:00
|
|
|
|
|
|
|
|
# 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
|
2025-11-21 15:27:54 +00:00
|
|
|
end
|
|
|
|
|
end
|