mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
PROBLEM: NPC unlocks had timing-dependent behavior: - If NPC unlocked door BEFORE room loaded: client saw it as unlocked - If NPC unlocked door AFTER room loaded: door sprite stayed locked SOLUTION: 1. Server-side persistent tracking: - Added npcUnlockedTargets array to player_state - Track all NPC unlocks separately from unlockedRooms/unlockedObjects - Initialize npcUnlockedTargets in new games 2. Server merges NPC unlock state: - filtered_room_data checks npcUnlockedTargets - Marks doors/containers as unlocked if NPC unlocked them - Works regardless of when room is loaded 3. Client updates existing sprites: - NPC unlock handler finds ALL door sprites for target room - Updates sprite state immediately after server unlock - Handles both pre-loaded and late-loaded rooms Changes: - app/models/break_escape/game.rb: Add npc_unlock_target!, npc_unlocked?, merge state in filtered_room_data - app/controllers/break_escape/games_controller.rb: Track NPC unlocks in unlock endpoint - public/break_escape/js/minigames/person-chat/person-chat-conversation.js: Update all door sprites after NPC unlock - public/break_escape/js/systems/doors.js: Export unlockDoor globally - test/integration/unlock_system_test.rb: Add 4 tests for persistent NPC unlock state
351 lines
12 KiB
Ruby
351 lines
12 KiB
Ruby
module BreakEscape
|
|
class Game < ApplicationRecord
|
|
self.table_name = 'break_escape_games'
|
|
|
|
# Associations
|
|
belongs_to :player, polymorphic: true
|
|
belongs_to :mission, class_name: 'BreakEscape::Mission'
|
|
|
|
# Validations
|
|
validates :player, presence: true
|
|
validates :mission, presence: true
|
|
validates :status, inclusion: { in: %w[in_progress completed abandoned] }
|
|
|
|
# Scopes
|
|
scope :active, -> { where(status: 'in_progress') }
|
|
scope :completed, -> { where(status: 'completed') }
|
|
|
|
# Callbacks
|
|
before_create :generate_scenario_data
|
|
before_create :initialize_player_state
|
|
before_create :set_started_at
|
|
|
|
# Room management
|
|
def unlock_room!(room_id)
|
|
player_state['unlockedRooms'] ||= []
|
|
player_state['unlockedRooms'] << room_id unless player_state['unlockedRooms'].include?(room_id)
|
|
save!
|
|
end
|
|
|
|
def room_unlocked?(room_id)
|
|
player_state['unlockedRooms']&.include?(room_id) || start_room?(room_id)
|
|
end
|
|
|
|
def start_room?(room_id)
|
|
scenario_data['startRoom'] == room_id
|
|
end
|
|
|
|
# Object management
|
|
def unlock_object!(object_id)
|
|
player_state['unlockedObjects'] ||= []
|
|
player_state['unlockedObjects'] << object_id unless player_state['unlockedObjects'].include?(object_id)
|
|
save!
|
|
end
|
|
|
|
def object_unlocked?(object_id)
|
|
player_state['unlockedObjects']&.include?(object_id)
|
|
end
|
|
|
|
# NPC unlock management
|
|
def npc_unlock_target!(target_id)
|
|
player_state['npcUnlockedTargets'] ||= []
|
|
player_state['npcUnlockedTargets'] << target_id unless player_state['npcUnlockedTargets'].include?(target_id)
|
|
save!
|
|
end
|
|
|
|
def npc_unlocked?(target_id)
|
|
player_state['npcUnlockedTargets']&.include?(target_id)
|
|
end
|
|
|
|
# Inventory management
|
|
def add_inventory_item!(item)
|
|
player_state['inventory'] ||= []
|
|
player_state['inventory'] << item
|
|
save!
|
|
end
|
|
|
|
def remove_inventory_item!(item_id)
|
|
player_state['inventory']&.reject! { |item| item['id'] == item_id }
|
|
save!
|
|
end
|
|
|
|
# NPC tracking
|
|
def encounter_npc!(npc_id)
|
|
player_state['encounteredNPCs'] ||= []
|
|
player_state['encounteredNPCs'] << npc_id unless player_state['encounteredNPCs'].include?(npc_id)
|
|
save!
|
|
end
|
|
|
|
# Global variables (synced with client)
|
|
def update_global_variables!(variables)
|
|
player_state['globalVariables'] ||= {}
|
|
player_state['globalVariables'].merge!(variables)
|
|
save!
|
|
end
|
|
|
|
# Minigame state
|
|
def add_biometric_sample!(sample)
|
|
player_state['biometricSamples'] ||= []
|
|
player_state['biometricSamples'] << sample
|
|
save!
|
|
end
|
|
|
|
def add_bluetooth_device!(device)
|
|
player_state['bluetoothDevices'] ||= []
|
|
unless player_state['bluetoothDevices'].any? { |d| d['mac'] == device['mac'] }
|
|
player_state['bluetoothDevices'] << device
|
|
end
|
|
save!
|
|
end
|
|
|
|
def add_note!(note)
|
|
player_state['notes'] ||= []
|
|
player_state['notes'] << note
|
|
save!
|
|
end
|
|
|
|
# Health management
|
|
def update_health!(value)
|
|
player_state['health'] = value.clamp(0, 100)
|
|
save!
|
|
end
|
|
|
|
# Scenario data access
|
|
def room_data(room_id)
|
|
scenario_data.dig('rooms', room_id)
|
|
end
|
|
|
|
def filtered_scenario_for_bootstrap
|
|
# Returns scenario data without room contents for lazy-loading
|
|
# This significantly reduces initial payload by only sending metadata
|
|
filtered = scenario_data.deep_dup
|
|
|
|
# Remove all room contents - they'll be lazy-loaded via /room/:room_id endpoint
|
|
if filtered['rooms'].present?
|
|
filtered['rooms'].each do |room_id, room_data|
|
|
# Keep only essential fields for navigation and metadata
|
|
# Build new hash with only the fields we want
|
|
kept_fields = {}
|
|
%w[type connections locked lockType requires difficulty door_sign].each do |field|
|
|
kept_fields[field] = room_data[field] if room_data.key?(field)
|
|
end
|
|
|
|
# Replace room data with filtered version
|
|
filtered['rooms'][room_id] = kept_fields
|
|
end
|
|
end
|
|
|
|
filtered
|
|
end
|
|
|
|
def filtered_room_data(room_id)
|
|
room = room_data(room_id)&.deep_dup
|
|
return nil unless room
|
|
|
|
# Merge NPC unlock state: If NPC unlocked this room, mark it as unlocked
|
|
if npc_unlocked?(room_id)
|
|
Rails.logger.info "[BreakEscape] Room #{room_id} was unlocked by NPC, marking as unlocked"
|
|
room['locked'] = false
|
|
end
|
|
|
|
# Merge NPC unlock state for objects/containers in this room
|
|
if room['objects'].present?
|
|
room['objects'].each do |obj|
|
|
obj_id = obj['id'] || obj['name']
|
|
if obj_id && npc_unlocked?(obj_id)
|
|
Rails.logger.info "[BreakEscape] Object #{obj_id} was unlocked by NPC, marking as unlocked"
|
|
obj['locked'] = false
|
|
end
|
|
end
|
|
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
|
|
|
|
# Unlock validation
|
|
def validate_unlock(target_type, target_id, attempt, method)
|
|
Rails.logger.info "[BreakEscape] validate_unlock: type=#{target_type}, id=#{target_id}, attempt=#{attempt}, method=#{method}"
|
|
|
|
if target_type == 'door'
|
|
room = room_data(target_id)
|
|
return false unless room
|
|
|
|
# SECURITY: Only allow 'unlocked' method if door is ACTUALLY unlocked in server data
|
|
# Client cannot be trusted - must validate against server state
|
|
if method == 'unlocked' && !room['locked']
|
|
Rails.logger.info "[BreakEscape] Door is unlocked in server data, granting access"
|
|
return true
|
|
end
|
|
|
|
# SECURITY: Reject 'unlocked' method for locked doors (client bypass attempt)
|
|
if method == 'unlocked' && room['locked']
|
|
Rails.logger.warn "[BreakEscape] SECURITY VIOLATION: Client sent method='unlocked' for LOCKED door #{target_id}"
|
|
return false
|
|
end
|
|
|
|
# NPC unlock: Validate NPC has been encountered and has permission to unlock this door
|
|
if method == 'npc'
|
|
npc_id = attempt # NPC id is passed as 'attempt'
|
|
return validate_npc_unlock(npc_id, target_id)
|
|
end
|
|
|
|
case method
|
|
when 'key', 'lockpick', 'biometric', 'bluetooth', 'rfid'
|
|
# Client validated the unlock - trust it
|
|
# (player had correct key, picked lock, had fingerprint, had bluetooth device, had RFID card)
|
|
true
|
|
when 'pin', 'password'
|
|
# Server validates password/PIN attempts
|
|
room['requires'].to_s == attempt.to_s
|
|
else
|
|
false
|
|
end
|
|
else
|
|
# Find object in all rooms - check both id and name
|
|
scenario_data['rooms'].each do |_room_id, room_data|
|
|
object = room_data['objects']&.find { |obj|
|
|
obj['id'] == target_id || obj['name'] == target_id
|
|
}
|
|
|
|
if object
|
|
Rails.logger.info "[BreakEscape] Found object: id=#{object['id']}, name=#{object['name']}, locked=#{object['locked']}, requires=#{object['requires']}"
|
|
|
|
# SECURITY: Only allow 'unlocked' method if object is ACTUALLY unlocked in server data
|
|
# Client cannot be trusted - must validate against server state
|
|
if method == 'unlocked' && !object['locked']
|
|
Rails.logger.info "[BreakEscape] Object is unlocked in server data, granting access"
|
|
return true
|
|
end
|
|
|
|
# SECURITY: Reject 'unlocked' method for locked objects (client bypass attempt)
|
|
if method == 'unlocked' && object['locked']
|
|
Rails.logger.warn "[BreakEscape] SECURITY VIOLATION: Client sent method='unlocked' for LOCKED object #{target_id}"
|
|
return false
|
|
end
|
|
|
|
# NPC unlock: Validate NPC has been encountered and has permission to unlock this object
|
|
if method == 'npc'
|
|
npc_id = attempt # NPC id is passed as 'attempt'
|
|
return validate_npc_unlock(npc_id, target_id)
|
|
end
|
|
|
|
case method
|
|
when 'key', 'lockpick', 'biometric', 'bluetooth', 'rfid'
|
|
# Client validated the unlock - trust it
|
|
return true
|
|
when 'pin', 'password'
|
|
result = object['requires'].to_s == attempt.to_s
|
|
Rails.logger.info "[BreakEscape] Password validation: required='#{object['requires']}', attempt='#{attempt}', result=#{result}"
|
|
return result
|
|
end
|
|
end
|
|
end
|
|
Rails.logger.warn "[BreakEscape] Object not found: #{target_id}"
|
|
false
|
|
end
|
|
end
|
|
|
|
# Validate NPC unlock permission
|
|
def validate_npc_unlock(npc_id, target_id)
|
|
Rails.logger.info "[BreakEscape] Validating NPC unlock: npc=#{npc_id}, target=#{target_id}"
|
|
|
|
# Find NPC in scenario data
|
|
npc = find_npc_in_scenario(npc_id)
|
|
unless npc
|
|
Rails.logger.warn "[BreakEscape] NPC not found: #{npc_id}"
|
|
return false
|
|
end
|
|
|
|
# Check if player has encountered this NPC
|
|
unless player_state['encounteredNPCs']&.include?(npc_id)
|
|
Rails.logger.warn "[BreakEscape] Player has not encountered NPC: #{npc_id}"
|
|
return false
|
|
end
|
|
|
|
# Check if NPC has permission to unlock this target
|
|
unlockable = npc['unlockable']
|
|
unless unlockable.is_a?(Array) && unlockable.include?(target_id)
|
|
Rails.logger.warn "[BreakEscape] NPC #{npc_id} does not have permission to unlock #{target_id}"
|
|
return false
|
|
end
|
|
|
|
Rails.logger.info "[BreakEscape] NPC unlock validated: #{npc_id} can unlock #{target_id}"
|
|
true
|
|
end
|
|
|
|
# Find NPC in scenario data
|
|
def find_npc_in_scenario(npc_id)
|
|
scenario_data['rooms']&.each do |_room_id, room_data|
|
|
room_data['npcs']&.each do |npc|
|
|
return npc if npc['id'] == npc_id
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
private
|
|
|
|
def filter_requires_and_contents_recursive(obj)
|
|
case obj
|
|
when Hash
|
|
# Remove 'requires' for exploitable lock types (key/pin/password/rfid)
|
|
# Keep it for biometric/bluetooth since they reference collectible items, not answers
|
|
lock_type = obj['lockType']
|
|
if lock_type && !%w[biometric bluetooth].include?(lock_type)
|
|
obj.delete('requires')
|
|
end
|
|
|
|
# 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
|
|
# Only generate scenario data if it's not already set (e.g., in tests)
|
|
self.scenario_data ||= mission.generate_scenario_data
|
|
end
|
|
|
|
def initialize_player_state
|
|
self.player_state ||= {}
|
|
self.player_state['currentRoom'] ||= scenario_data['startRoom']
|
|
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['npcUnlockedTargets'] ||= []
|
|
self.player_state['globalVariables'] ||= {}
|
|
self.player_state['biometricSamples'] ||= []
|
|
self.player_state['biometricUnlocks'] ||= []
|
|
self.player_state['bluetoothDevices'] ||= []
|
|
self.player_state['notes'] ||= []
|
|
self.player_state['health'] ||= 100
|
|
end
|
|
|
|
def set_started_at
|
|
self.started_at ||= Time.current
|
|
end
|
|
end
|
|
end
|