Simplify NPC unlock to use standard unlock flow

PROBLEM:
Previous implementation had unnecessary complexity with npcUnlockedTargets
tracking. NPC unlocks should just work like any other unlock method.

SOLUTION:
1. Removed npcUnlockedTargets tracking (not needed)
2. NPC unlocks now use standard unlockedRooms/unlockedObjects arrays
3. Updated validate_unlock to check if already unlocked FIRST:
   - If in player_state unlocked list → grant access
   - If method='unlocked' → verify against scenario data locked field
   - Otherwise → validate normally

This fixes the race condition issue:
- NPC calls unlock API with method='npc'
- Server validates NPC has permission
- Server adds to unlockedRooms (normal unlock)
- Later when player opens door, client sends method='unlocked'
- Server sees it's already in unlockedRooms OR unlocked in scenario → grants access

Changes:
- app/models/break_escape/game.rb: Remove npc_unlock_target!/npc_unlocked?, check unlocked state first
- app/controllers/break_escape/games_controller.rb: Remove npc_unlock_target! calls
- test/integration/unlock_system_test.rb: Update tests for simplified approach

All 37 tests passing, 122 assertions
This commit is contained in:
Z. Cliffe Schreuders
2025-11-22 00:46:56 +00:00
parent a36c0da04e
commit ef27265c8c
5 changed files with 1888 additions and 90 deletions

View File

@@ -241,8 +241,6 @@ module BreakEscape
ActiveRecord::Base.transaction do
if target_type == 'door'
@game.unlock_room!(target_id)
# For NPC unlocks, also track in npcUnlockedTargets for persistent state
@game.npc_unlock_target!(target_id) if method == 'npc'
room_data = @game.filtered_room_data(target_id)
@@ -254,8 +252,6 @@ module BreakEscape
else
# Object/container unlock
@game.unlock_object!(target_id)
# For NPC unlocks, also track in npcUnlockedTargets for persistent state
@game.npc_unlock_target!(target_id) if method == 'npc'
# Find the unlocked object and return its contents if it's a container
object_data = find_object_in_scenario(target_id)

View File

@@ -46,17 +46,6 @@ module BreakEscape
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'] ||= []
@@ -142,23 +131,6 @@ module BreakEscape
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)
@@ -171,20 +143,24 @@ module BreakEscape
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"
# Check if already unlocked in player state (grants access regardless of method)
if room_unlocked?(target_id)
Rails.logger.info "[BreakEscape] Door already unlocked in player state, 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
room = room_data(target_id)
return false unless room
# Handle method='unlocked' - verify against scenario data
if method == 'unlocked'
if !room['locked']
Rails.logger.info "[BreakEscape] Door is unlocked in scenario data, granting access"
return true
else
Rails.logger.warn "[BreakEscape] SECURITY VIOLATION: Client sent method='unlocked' for LOCKED door: #{target_id}"
return false
end
end
# NPC unlock: Validate NPC has been encountered and has permission to unlock this door
@@ -205,6 +181,12 @@ module BreakEscape
false
end
else
# Check if already unlocked in player state (grants access regardless of method)
if object_unlocked?(target_id)
Rails.logger.info "[BreakEscape] Object already unlocked in player state, granting access"
return true
end
# 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|
@@ -214,17 +196,15 @@ module BreakEscape
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
# Handle method='unlocked' - verify against scenario data
if method == 'unlocked'
if !object['locked']
Rails.logger.info "[BreakEscape] Object is unlocked in scenario data, granting access"
return true
else
Rails.logger.warn "[BreakEscape] SECURITY VIOLATION: Client sent method='unlocked' for LOCKED object: #{target_id}"
return false
end
end
# NPC unlock: Validate NPC has been encountered and has permission to unlock this object
@@ -335,7 +315,6 @@ module BreakEscape
end
self.player_state['encounteredNPCs'] ||= []
self.player_state['npcUnlockedTargets'] ||= []
self.player_state['globalVariables'] ||= {}
self.player_state['biometricSamples'] ||= []
self.player_state['biometricUnlocks'] ||= []