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
This commit is contained in:
Z. Cliffe Schreuders
2025-11-22 00:46:56 +00:00
parent d3b31b4368
commit c5eca9cc60
7 changed files with 1931 additions and 8 deletions

View File

@@ -241,6 +241,8 @@ 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)
@@ -252,6 +254,8 @@ 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,6 +46,17 @@ 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'] ||= []
@@ -131,6 +142,23 @@ 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)
@@ -306,6 +334,7 @@ module BreakEscape
end
self.player_state['encounteredNPCs'] ||= []
self.player_state['npcUnlockedTargets'] ||= []
self.player_state['globalVariables'] ||= {}
self.player_state['biometricSamples'] ||= []
self.player_state['biometricUnlocks'] ||= []

View File

@@ -465,10 +465,16 @@ export default class PersonChatConversation {
console.log(`✅ NPC ${this.npc.id} successfully unlocked door ${doorId}`);
window.gameAlert(`Door unlocked!`, 'success', 'Access Granted', 3000);
// Trigger door unlock visual update if door sprite exists
const doorSprite = this.findDoorSprite(doorId);
if (doorSprite && window.unlockDoor) {
// Trigger door unlock visual update for ALL door sprites leading to this room
// This handles the case where the room is already loaded
const doorSprites = this.findAllDoorSprites(doorId);
if (doorSprites.length > 0 && window.unlockDoor) {
console.log(`📍 Found ${doorSprites.length} door sprite(s) to update`);
doorSprites.forEach(doorSprite => {
window.unlockDoor(doorSprite, response.roomData);
});
} else {
console.log(`📍 No door sprites found for ${doorId}, will be unlocked when room loads`);
}
} else {
console.error('NPC unlock failed:', response);
@@ -481,18 +487,28 @@ export default class PersonChatConversation {
}
/**
* Find door sprite by room ID
* @param {string} roomId - Room ID to find door for
* Find all door sprites leading to the given room ID
* @param {string} roomId - Room ID to find doors for
* @returns {Array} Array of door sprites leading to the room
*/
findDoorSprite(roomId) {
findAllDoorSprites(roomId) {
// Search through all door sprites in the game
if (!window.game || !window.game.children) return null;
if (!window.game || !window.game.children) return [];
const doors = window.game.children.list.filter(child =>
child.doorProperties &&
child.doorProperties.connectedRoom === roomId
);
return doors;
}
/**
* Find door sprite by room ID (legacy, returns first match)
* @param {string} roomId - Room ID to find door for
*/
findDoorSprite(roomId) {
const doors = this.findAllDoorSprites(roomId);
return doors.length > 0 ? doors[0] : null;
}

View File

@@ -597,6 +597,9 @@ function unlockDoor(doorSprite, roomData) {
openDoor(doorSprite);
}
// Make unlockDoor globally available for NPC unlock handlers
window.unlockDoor = unlockDoor;
// Function to open a door
function openDoor(doorSprite) {
const props = doorSprite.doorProperties;

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -830,5 +830,136 @@ module BreakEscape
assert_equal 2, @game.player_state['unlockedRooms'].length,
"Room should only appear once in unlockedRooms"
end
# =============================================================================
# NPC UNLOCK PERSISTENT STATE TESTS
# =============================================================================
test "NPC unlock is tracked in npcUnlockedTargets" do
# Set up NPC with unlock permission
@game.scenario_data['rooms']['lobby']['npcs'] = [
{
'id' => 'helper_npc',
'displayName' => 'Helpful Contact',
'unlockable' => ['office_pin']
}
]
@game.player_state['encounteredNPCs'] = ['helper_npc']
@game.save!
# NPC unlocks door
post unlock_game_url(@game), params: {
targetType: 'door',
targetId: 'office_pin',
attempt: 'helper_npc',
method: 'npc'
}
assert_response :success
@game.reload
# Verify tracked in both unlockedRooms and npcUnlockedTargets
assert_includes @game.player_state['unlockedRooms'], 'office_pin',
"NPC unlock should add room to unlockedRooms"
assert_includes @game.player_state['npcUnlockedTargets'], 'office_pin',
"NPC unlock should track target in npcUnlockedTargets for persistent state"
end
test "filtered_room_data marks NPC-unlocked door as unlocked (race condition fix)" do
# Set up a locked room that will be unlocked by NPC
@game.scenario_data['rooms']['ceo'] = {
'type' => 'office',
'locked' => true,
'lockType' => 'password',
'requires' => 'TopSecret123',
'connections' => { 'south' => 'lobby' },
'objects' => []
}
@game.scenario_data['rooms']['lobby']['npcs'] = [
{
'id' => 'helper_npc',
'unlockable' => ['ceo']
}
]
@game.player_state['encounteredNPCs'] = ['helper_npc']
@game.save!
# NPC unlocks the door
post unlock_game_url(@game), params: {
targetType: 'door',
targetId: 'ceo',
attempt: 'helper_npc',
method: 'npc'
}
assert_response :success
# Now load the room (simulating loading after NPC unlock)
get room_game_url(@game, room_id: 'ceo')
assert_response :success
json = JSON.parse(@response.body)
room_data = json['room']
# The room should be marked as unlocked even though scenario data has locked: true
assert_equal false, room_data['locked'],
"Room should be marked unlocked when NPC has unlocked it (fixes race condition)"
end
test "filtered_room_data marks NPC-unlocked container as unlocked" do
# Set up a locked container
@game.scenario_data['rooms']['lobby']['objects'] = [
{
'id' => 'npc_safe',
'type' => 'safe1',
'locked' => true,
'lockType' => 'pin',
'requires' => '9999',
'contents' => [
{ 'type' => 'key', 'id' => 'master_key', 'takeable' => true }
]
}
]
@game.scenario_data['rooms']['lobby']['npcs'] = [
{
'id' => 'helper_npc',
'unlockable' => ['npc_safe']
}
]
@game.player_state['encounteredNPCs'] = ['helper_npc']
@game.save!
# NPC unlocks the container
post unlock_game_url(@game), params: {
targetType: 'object',
targetId: 'npc_safe',
attempt: 'helper_npc',
method: 'npc'
}
assert_response :success
# Now load the room (simulating loading after NPC unlock)
get room_game_url(@game, room_id: 'lobby')
assert_response :success
json = JSON.parse(@response.body)
room_data = json['room']
safe = room_data['objects'].find { |obj| obj['id'] == 'npc_safe' }
assert_not_nil safe, "Safe should be in room data"
assert_equal false, safe['locked'],
"Container should be marked unlocked when NPC has unlocked it"
end
test "npcUnlockedTargets is initialized in new game player_state" do
new_game = Game.create!(
mission: @mission,
player: @player
)
assert_not_nil new_game.player_state['npcUnlockedTargets'],
"npcUnlockedTargets should be initialized"
assert_equal [], new_game.player_state['npcUnlockedTargets'],
"npcUnlockedTargets should be initialized as empty array"
end
end
end