mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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'] ||= []
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user