diff --git a/app/controllers/break_escape/games_controller.rb b/app/controllers/break_escape/games_controller.rb index d634951..dad53f4 100644 --- a/app/controllers/break_escape/games_controller.rb +++ b/app/controllers/break_escape/games_controller.rb @@ -2,7 +2,7 @@ require 'open3' module BreakEscape class GamesController < ApplicationController - before_action :set_game, only: [:show, :scenario, :ink, :room, :sync_state, :unlock, :inventory] + before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory] def show authorize @game if defined?(Pundit) @@ -16,6 +16,31 @@ module BreakEscape render json: @game.scenario_data end + # GET /games/:id/scenario_map + # Returns minimal scenario metadata for navigation (no room contents) + def scenario_map + authorize @game if defined?(Pundit) + + # Return minimal room/connection metadata without contents + layout = {} + @game.scenario_data['rooms'].each do |room_id, room_data| + 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) + } + end + + render json: { + startRoom: @game.scenario_data['startRoom'], + currentRoom: @game.player_state['currentRoom'], + rooms: layout + } + end + # GET /games/:id/room/:room_id # Returns room data for a specific room (lazy-loading support) def room @@ -24,16 +49,63 @@ module BreakEscape room_id = params[:room_id] return render_error('Missing room_id parameter', :bad_request) unless room_id.present? - # Get room data from scenario - room_data = @game.scenario_data['rooms']&.[](room_id) + # 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 start room if missing (defensive programming) + if is_start_room && !is_unlocked + @game.player_state['unlockedRooms'] ||= [] + @game.player_state['unlockedRooms'] << room_id + @game.save! + 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 + track_npc_encounters(room_id, room_data) + Rails.logger.debug "[BreakEscape] Serving room data for: #{room_id}" - # Return room data with the room_id for client reference render json: { room_id: room_id, room: room_data } end + # GET /games/:id/container/:container_id + # Returns container contents after unlock (lazy-loaded) + def container + authorize @game if defined?(Pundit) + + 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.debug "[BreakEscape] Serving container contents for: #{container_id}" + + render json: { + container_id: container_id, + contents: contents + } + end + # GET /games/:id/ink?npc=helper1 # Returns NPC script (JIT compiled if needed) def ink @@ -78,7 +150,16 @@ module BreakEscape # Update allowed fields if params[:currentRoom] - @game.player_state['currentRoom'] = 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] @@ -102,9 +183,18 @@ module BreakEscape is_valid = @game.validate_unlock(target_type, target_id, attempt, method) - if is_valid + 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) render json: { @@ -113,18 +203,20 @@ module BreakEscape roomData: room_data } else + # Object/container unlock @game.unlock_object!(target_id) + render json: { success: true, type: 'object' } end - else - render json: { - success: false, - message: 'Invalid attempt' - }, status: :unprocessable_entity end + rescue ActiveRecord::RecordInvalid => e + render json: { + success: false, + message: "Failed to save unlock: #{e.message}" + }, status: :unprocessable_entity end # POST /games/:id/inventory @@ -137,11 +229,20 @@ module BreakEscape case action_type when 'add' + # Validate item exists and is collectible + validation_error = validate_item_collectible(item) + if validation_error + return render json: { success: false, message: validation_error }, + status: :unprocessable_entity + end + @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 @@ -153,6 +254,195 @@ module BreakEscape @game = Game.find(params[:id]) end + def track_npc_encounters(room_id, room_data) + return unless room_data['npcs'].present? + + npc_ids = room_data['npcs'].map { |npc| npc['id'] } + @game.player_state['encounteredNPCs'] ||= [] + + new_npcs = npc_ids - @game.player_state['encounteredNPCs'] + return if new_npcs.empty? + + @game.player_state['encounteredNPCs'].concat(new_npcs) + @game.save! + + Rails.logger.debug "[BreakEscape] Tracked NPC encounters: #{new_npcs.join(', ')}" + 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 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 || [] + + contents + end + + def validate_item_collectible(item) + item_type = item['type'] + item_id = item['id'] + + # Search scenario for this item + found_item = find_item_in_scenario(item_type, item_id) + return "Item not found in scenario: #{item_type}" unless found_item + + # Check if item is takeable + unless found_item['takeable'] + return "Item is not takeable: #{found_item['name']}" + end + + # If item is in locked container, check if container is unlocked + container_info = find_item_container(item_type, item_id) + if container_info.present? + container_id = container_info[:id] + unless @game.player_state['unlockedObjects'].include?(container_id) + return "Container not unlocked: #{container_id}" + end + end + + # If item is in locked room, check if room is unlocked + room_info = find_item_room(item_type, item_id) + if room_info.present? + room_id = room_info[:id] + if room_info[:locked] && !@game.player_state['unlockedRooms'].include?(room_id) + return "Room not unlocked: #{room_id}" + end + end + + # Check if NPC holds this item and if NPC encountered + npc_info = find_npc_holding_item(item_type, item_id) + if npc_info.present? + npc_id = npc_info[:id] + unless @game.player_state['encounteredNPCs'].include?(npc_id) + return "NPC not encountered: #{npc_id}" + end + end + + nil # No error + end + + def find_item_in_scenario(item_type, item_id) + @game.scenario_data['rooms'].each do |room_id, room_data| + # Search room objects + room_data['objects']&.each do |obj| + return obj if obj['type'] == item_type && (obj['id'] == item_id || obj['name'] == item_id) + + # Search nested contents + obj['contents']&.each do |content| + return content if content['type'] == item_type && (content['id'] == item_id || content['name'] == item_id) + 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| diff --git a/app/models/break_escape/game.rb b/app/models/break_escape/game.rb index 0da0fde..4c7e6a8 100644 --- a/app/models/break_escape/game.rb +++ b/app/models/break_escape/game.rb @@ -131,16 +131,9 @@ module BreakEscape room = room_data(room_id)&.deep_dup return nil unless room - # Remove solutions - room.delete('requires') - room.delete('lockType') if room['locked'] - - # Remove solutions from objects - room['objects']&.each do |obj| - obj.delete('requires') - obj.delete('lockType') if obj['locked'] - obj.delete('contents') if obj['locked'] - end + # Remove ONLY the 'requires' field (the solution) and locked 'contents' + # Keep lockType, locked, observations visible to client + filter_requires_and_contents_recursive(room) room end @@ -182,6 +175,27 @@ module BreakEscape private + def filter_requires_and_contents_recursive(obj) + case obj + when Hash + # Remove 'requires' (the answer/solution) + obj.delete('requires') + + # Remove 'contents' if locked (lazy-loaded via separate endpoint) + obj.delete('contents') if obj['locked'] + + # Keep lockType - client needs it to show correct UI + # Keep locked - client needs it to show lock status + + # Recursively filter nested objects and NPCs + obj['objects']&.each { |o| filter_requires_and_contents_recursive(o) } + obj['npcs']&.each { |n| filter_requires_and_contents_recursive(n) } + + when Array + obj.each { |item| filter_requires_and_contents_recursive(item) } + end + end + def generate_scenario_data self.scenario_data = mission.generate_scenario_data end @@ -192,6 +206,14 @@ module BreakEscape self.player_state['unlockedRooms'] ||= [scenario_data['startRoom']] self.player_state['unlockedObjects'] ||= [] self.player_state['inventory'] ||= [] + + # Initialize starting items from scenario + if scenario_data['startItemsInInventory'].present? + scenario_data['startItemsInInventory'].each do |item| + self.player_state['inventory'] << item.deep_dup + end + end + self.player_state['encounteredNPCs'] ||= [] self.player_state['globalVariables'] ||= {} self.player_state['biometricSamples'] ||= [] diff --git a/config/routes.rb b/config/routes.rb index e12079a..fcd5f30 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,9 +15,11 @@ BreakEscape::Engine.routes.draw do 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) - get 'room/:room_id', to: 'games#room' # Returns room data for lazy-loading + get 'scenario' # Returns full scenario_data JSON (for compatibility) + get 'scenario_map' # Returns minimal layout metadata for navigation + get 'ink' # Returns NPC script (JIT compiled) + get 'room/:room_id', to: 'games#room' # Returns room data for lazy-loading + get 'container/:container_id', to: 'games#container' # Returns locked container contents # Game state and actions put 'sync_state' # Periodic state sync diff --git a/public/break_escape/js/api-client.js b/public/break_escape/js/api-client.js index 6940718..5c7c891 100644 --- a/public/break_escape/js/api-client.js +++ b/public/break_escape/js/api-client.js @@ -68,11 +68,16 @@ export class ApiClient { return response.json(); } - // Get scenario JSON + // Get scenario JSON (full scenario data) static async getScenario() { return this.get('/scenario'); } + // Get scenario map (minimal layout metadata for navigation) + static async getScenarioMap() { + return this.get('/scenario_map'); + } + // Get NPC script static async getNPCScript(npcId) { return this.get(`/ink?npc=${npcId}`); diff --git a/public/break_escape/js/core/game.js b/public/break_escape/js/core/game.js index 71a074d..c5acfbc 100644 --- a/public/break_escape/js/core/game.js +++ b/public/break_escape/js/core/game.js @@ -441,9 +441,9 @@ export function preload() { // Load scenario from Rails API endpoint if available, otherwise try URL parameter if (window.breakEscapeConfig?.apiBasePath) { - // Load scenario from Rails API endpoint + // Load scenario map from Rails API endpoint (minimal metadata for lazy-loading) // Use absolute URL with origin to prevent Phaser baseURL from interfering - const scenarioUrl = `${window.location.origin}${window.breakEscapeConfig.apiBasePath}/scenario`; + const scenarioUrl = `${window.location.origin}${window.breakEscapeConfig.apiBasePath}/scenario_map`; this.load.json('gameScenarioJSON', scenarioUrl); } else { // Fallback to old behavior for standalone HTML files diff --git a/public/break_escape/js/minigames/container/container-minigame.js b/public/break_escape/js/minigames/container/container-minigame.js index 6a24e27..ed56a07 100644 --- a/public/break_escape/js/minigames/container/container-minigame.js +++ b/public/break_escape/js/minigames/container/container-minigame.js @@ -24,23 +24,64 @@ export class ContainerMinigame extends MinigameScene { const containerName = this.containerItem?.scenarioData?.name?.toLowerCase() || ''; const containerType = this.containerItem?.scenarioData?.type?.toLowerCase() || ''; const containerImage = this.containerItem?.name?.toLowerCase() || ''; - + // Keywords that indicate desktop/computer devices const desktopKeywords = [ 'computer', 'pc', 'laptop', 'desktop', 'terminal', 'workstation', 'tablet', 'ipad', 'surface', 'monitor', 'screen', 'display', 'server', 'mainframe', 'console', 'kiosk', 'smartboard' ]; - + // Check if any keyword matches const allText = `${containerName} ${containerType} ${containerImage}`.toLowerCase(); return desktopKeywords.some(keyword => allText.includes(keyword)); } - - init() { + + async loadContainerContents() { + const gameId = window.gameId; + const containerId = this.containerItem.scenarioData.id || + this.containerItem.scenarioData.name || + this.containerItem.objectId; + + if (!gameId) { + console.error('No gameId available for container loading'); + return []; + } + + console.log(`Loading contents for container: ${containerId}`); + + try { + const response = await fetch(`/break_escape/games/${gameId}/container/${containerId}`, { + headers: { 'Accept': 'application/json' } + }); + + if (!response.ok) { + if (response.status === 403) { + if (window.gameAlert) { + window.gameAlert('Container is locked', 'error', 'Locked', 2000); + } + this.complete(false); + return []; + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log(`Loaded ${data.contents?.length || 0} items from container`); + return data.contents || []; + } catch (error) { + console.error('Failed to load container contents:', error); + if (window.gameAlert) { + window.gameAlert('Could not load container contents', 'error', 'Error', 3000); + } + return []; + } + } + + async init() { // Call parent init first super.init(); - + // Update header with container name if (this.headerElement) { this.headerElement.innerHTML = ` @@ -48,7 +89,7 @@ export class ContainerMinigame extends MinigameScene {
${this.containerItem.scenarioData.observations || ''}
`; } - + // Add notebook button to minigame controls if postit note exists (before cancel button) if (this.controlsElement && this.containerItem.scenarioData.postitNote && this.containerItem.scenarioData.showPostit) { const notebookBtn = document.createElement('button'); @@ -58,7 +99,16 @@ export class ContainerMinigame extends MinigameScene { // Insert before the cancel button (first child in controls) this.controlsElement.insertBefore(notebookBtn, this.controlsElement.firstChild); } - + + // Show loading state + this.gameContainer.innerHTML = '