mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
Implement comprehensive server-side validation and data filtering for client actions
Server-side changes: - Game model: Initialize starting items in player inventory from scenario - Game model: Add filter_requires_and_contents_recursive to hide solutions and locked contents - Game model: Fix filtered_room_data to preserve lockType while removing requires - GamesController: Add scenario_map endpoint for minimal layout metadata - GamesController: Update room endpoint with access control and NPC encounter tracking - GamesController: Add container endpoint for lazy-loading locked container contents - GamesController: Update inventory endpoint with comprehensive validation - Validates item exists in scenario - Checks item is takeable - Verifies container is unlocked if item is in container - Verifies room is unlocked if room is locked - Checks NPC is encountered if item held by NPC - GamesController: Update unlock endpoint with transaction safety - GamesController: Update sync_state to verify room accessibility - Routes: Add scenario_map and container routes Client-side changes: - inventory.js: Make addToInventory async and add server validation before local updates - container-minigame.js: Add lazy-loading of container contents from server - game.js: Update to use scenario_map endpoint for reduced initial payload - api-client.js: Add getScenarioMap method alongside getScenario Security improvements: - Prevents client-side cheating by validating all actions server-side - Hides solution fields (requires) from client responses - Hides locked container contents until unlocked - Enforces room and container access controls - Tracks NPC encounters automatically - All validation failures return clear error messages Implements plans from: - planning_notes/validate_client_actions/GOALS_AND_DECISIONS.md - planning_notes/validate_client_actions/IMPLEMENTATION_PLAN.md
This commit is contained in:
@@ -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|
|
||||
|
||||
@@ -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'] ||= []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
<p>${this.containerItem.scenarioData.observations || ''}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// 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 = '<div class="loading" style="text-align: center; padding: 20px;">Loading contents...</div>';
|
||||
|
||||
// Load contents from server (if gameId exists and container is not locked)
|
||||
if (window.gameId && this.containerItem.scenarioData.locked === false) {
|
||||
this.contents = await this.loadContainerContents();
|
||||
}
|
||||
// Otherwise use contents passed in (for unlocked containers or local game)
|
||||
|
||||
// Create the container minigame UI
|
||||
this.createContainerUI();
|
||||
}
|
||||
|
||||
@@ -188,12 +188,12 @@ function createInventorySprite(itemData) {
|
||||
}
|
||||
}
|
||||
|
||||
export function addToInventory(sprite) {
|
||||
export async function addToInventory(sprite) {
|
||||
if (!sprite || !sprite.scenarioData) {
|
||||
console.warn('Invalid sprite for inventory');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
console.log("Adding to inventory:", {
|
||||
objectId: sprite.objectId,
|
||||
@@ -201,18 +201,57 @@ export function addToInventory(sprite) {
|
||||
type: sprite.scenarioData?.type,
|
||||
currentRoom: window.currentPlayerRoom
|
||||
});
|
||||
|
||||
// Check if the item is already in the inventory
|
||||
|
||||
// Check if the item is already in the inventory (local check first)
|
||||
const itemIdentifier = createItemIdentifier(sprite.scenarioData);
|
||||
const isAlreadyInInventory = window.inventory.items.some(item =>
|
||||
const isAlreadyInInventory = window.inventory.items.some(item =>
|
||||
item && createItemIdentifier(item.scenarioData) === itemIdentifier
|
||||
);
|
||||
|
||||
|
||||
if (isAlreadyInInventory) {
|
||||
console.log(`Item ${itemIdentifier} is already in inventory`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// NEW: Validate with server before adding
|
||||
const gameId = window.gameId;
|
||||
if (gameId) {
|
||||
try {
|
||||
const response = await fetch(`/break_escape/games/${gameId}/inventory`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action_type: 'add',
|
||||
item: sprite.scenarioData
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
// Server rejected - show error to player
|
||||
console.warn('Server rejected inventory add:', result.message);
|
||||
if (window.gameAlert) {
|
||||
window.gameAlert(result.message || 'Cannot collect this item', 'error', 'Invalid Action', 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Server accepted - continue with local inventory update
|
||||
console.log('Server validated item collection:', result);
|
||||
} catch (error) {
|
||||
console.error('Failed to validate inventory with server:', error);
|
||||
// Fail closed - don't add if server can't validate
|
||||
if (window.gameAlert) {
|
||||
window.gameAlert('Network error - please try again', 'error', 'Error', 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from room if it exists
|
||||
if (window.currentPlayerRoom && rooms[window.currentPlayerRoom] && rooms[window.currentPlayerRoom].objects) {
|
||||
if (rooms[window.currentPlayerRoom].objects[sprite.objectId]) {
|
||||
|
||||
Reference in New Issue
Block a user