Files
BreakEscape/app/models/break_escape/game.rb
Z. Cliffe Schreuders c5eca9cc60 Fix NPC unlock race condition with persistent server-side tracking
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
2025-11-22 00:46:56 +00:00

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