From f6c9ceb5e8821668af1699e68c9cf8d2eaedcaa0 Mon Sep 17 00:00:00 2001 From: "Z. Cliffe Schreuders" Date: Fri, 21 Nov 2025 15:27:54 +0000 Subject: [PATCH] feat: Add controllers and routes - Add MissionsController for scenario selection - Add GamesController with scenario/ink endpoints - Add JIT Ink compilation logic - Add API::GamesController for game state management - Configure routes with REST + API endpoints - Add authorization hooks (Pundit) - Add polymorphic player support (current_player helper) --- .../break_escape/api/games_controller.rb | 114 +++++++++++++++++ .../break_escape/application_controller.rb | 26 ++++ .../break_escape/games_controller.rb | 119 ++++++++++++++++++ .../break_escape/missions_controller.rb | 24 ++++ config/routes.rb | 21 ++++ 5 files changed, 304 insertions(+) create mode 100644 app/controllers/break_escape/api/games_controller.rb create mode 100644 app/controllers/break_escape/games_controller.rb create mode 100644 app/controllers/break_escape/missions_controller.rb diff --git a/app/controllers/break_escape/api/games_controller.rb b/app/controllers/break_escape/api/games_controller.rb new file mode 100644 index 0000000..4918996 --- /dev/null +++ b/app/controllers/break_escape/api/games_controller.rb @@ -0,0 +1,114 @@ +module BreakEscape + module Api + class GamesController < ApplicationController + before_action :set_game + + # GET /games/:id/bootstrap + # Initial game data for client + def bootstrap + authorize @game if defined?(Pundit) + + render json: { + gameId: @game.id, + missionName: @game.mission.display_name, + startRoom: @game.scenario_data['startRoom'], + playerState: @game.player_state, + roomLayout: build_room_layout + } + 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] + @game.player_state['currentRoom'] = params[:currentRoom] + 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) + + if is_valid + if target_type == 'door' + @game.unlock_room!(target_id) + room_data = @game.filtered_room_data(target_id) + + render json: { + success: true, + type: 'door', + roomData: room_data + } + else + @game.unlock_object!(target_id) + render json: { + success: true, + type: 'object' + } + end + else + render json: { + success: false, + message: 'Invalid attempt' + }, status: :unprocessable_entity + end + end + + # POST /games/:id/inventory + # Update inventory + def inventory + authorize @game if defined?(Pundit) + + action = params[:action] + item = params[:item] + + case action + when 'add' + @game.add_inventory_item!(item.to_unsafe_h) + 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 + end + + private + + def set_game + @game = Game.find(params[:id]) + end + + def build_room_layout + layout = {} + @game.scenario_data['rooms'].each do |room_id, room_data| + layout[room_id] = { + connections: room_data['connections'], + locked: room_data['locked'] || false + } + end + layout + end + end + end +end diff --git a/app/controllers/break_escape/application_controller.rb b/app/controllers/break_escape/application_controller.rb index 56b0a5c..54a1911 100644 --- a/app/controllers/break_escape/application_controller.rb +++ b/app/controllers/break_escape/application_controller.rb @@ -1,4 +1,30 @@ module BreakEscape class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + + # Include Pundit if available + include Pundit::Authorization if defined?(Pundit) + + # Helper method to get current player (polymorphic) + def current_player + if BreakEscape.standalone_mode? + # Standalone mode - get/create demo user + @current_player ||= DemoUser.first_or_create!(handle: 'demo_player') + else + # Mounted mode - use Hacktivity's current_user + current_user + end + end + helper_method :current_player + + # Handle authorization errors + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + private + + def user_not_authorized + flash[:alert] = "You are not authorized to perform this action." + redirect_to(request.referrer || root_path) + end end end diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb new file mode 100644 index 0000000..2157aac --- /dev/null +++ b/app/controllers/break_escape/games_controller.rb @@ -0,0 +1,119 @@ +require 'open3' + +module BreakEscape + class GamesController < ApplicationController + before_action :set_game, only: [:show, :scenario, :ink] + + def show + authorize @game if defined?(Pundit) + @mission = @game.mission + end + + # GET /games/:id/scenario + # Returns scenario JSON for this game instance + def scenario + authorize @game if defined?(Pundit) + render json: @game.scenario_data + 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? + + # Find NPC in scenario data + npc = find_npc_in_scenario(npc_id) + return render_error('NPC not found in scenario', :not_found) unless npc + + # Resolve ink file path and compile if needed + ink_json_path = resolve_and_compile_ink(npc['storyPath']) + return render_error('Ink script not found', :not_found) unless ink_json_path && File.exist?(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 + + private + + def set_game + @game = Game.find(params[:id]) + end + + def find_npc_in_scenario(npc_id) + @game.scenario_data['rooms']&.each do |_room_id, room_data| + npc = room_data['npcs']&.find { |n| n['id'] == npc_id } + return npc if npc + end + nil + end + + # Resolve ink path and compile if necessary + def resolve_and_compile_ink(story_path) + base_path = Rails.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 = Rails.root.join('bin', 'inklecate') + + 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 + end +end diff --git a/app/controllers/break_escape/missions_controller.rb b/app/controllers/break_escape/missions_controller.rb new file mode 100644 index 0000000..fa3d79b --- /dev/null +++ b/app/controllers/break_escape/missions_controller.rb @@ -0,0 +1,24 @@ +module BreakEscape + class MissionsController < ApplicationController + def index + @missions = if defined?(Pundit) + policy_scope(Mission) + else + Mission.published + end + end + + def show + @mission = Mission.find(params[:id]) + authorize @mission if defined?(Pundit) + + # Create or find game instance for current player + @game = Game.find_or_create_by!( + player: current_player, + mission: @mission + ) + + redirect_to game_path(@game) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 8948152..e99c303 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,2 +1,23 @@ BreakEscape::Engine.routes.draw do + # Mission selection + resources :missions, only: [:index, :show] + + # Game management + resources :games, only: [:show, :create] do + member do + # Scenario and NPC data + get 'scenario' # Returns scenario_data JSON + get 'ink' # Returns NPC script (JIT compiled) + + # API endpoints + scope module: :api do + get 'bootstrap' # Initial game data + put 'sync_state' # Periodic state sync + post 'unlock' # Validate unlock attempt + post 'inventory' # Update inventory + end + end + end + + root to: 'missions#index' end