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)
This commit is contained in:
Z. Cliffe Schreuders
2025-11-21 15:27:54 +00:00
parent 4681c75c83
commit f6c9ceb5e8
5 changed files with 304 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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