feat: Implement dynamic room state management with server synchronization for items, NPCs, and object states

This commit is contained in:
Z. Cliffe Schreuders
2026-02-16 23:51:59 +00:00
parent 8c8fded7a1
commit e18e1d7228
9 changed files with 883 additions and 3 deletions

View File

@@ -2,7 +2,7 @@ require 'open3'
module BreakEscape
class GamesController < ApplicationController
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :unlock, :inventory, :objectives, :complete_task, :update_task_progress, :submit_flag]
before_action :set_game, only: [:show, :scenario, :scenario_map, :ink, :room, :container, :sync_state, :update_room, :unlock, :inventory, :objectives, :complete_task, :update_task_progress, :submit_flag]
# GET /games/new?mission_id=:id
# Show VM set selection page for VM-required missions
@@ -344,6 +344,78 @@ module BreakEscape
render json: { success: true }
end
# POST /games/:id/update_room
# Update dynamic room state (items added/removed, NPCs moved, object state changes)
def update_room
authorize @game if defined?(Pundit)
room_id = params[:roomId]
action_type = params[:actionType] # Renamed from 'action' to avoid Rails conflict
data = params[:data]
unless room_id.present? && action_type.present?
return render json: { success: false, message: 'Missing roomId or actionType' }, status: :bad_request
end
# Validate room is accessible
unless @game.room_unlocked?(room_id)
return render json: { success: false, message: 'Room not accessible' }, status: :forbidden
end
success = case action_type
when 'add_object'
# Validate item data (data is ActionController::Parameters)
unless data.present? && (data[:type].present? || data['type'].present?)
return render json: { success: false, message: 'Invalid item data' }, status: :bad_request
end
source_data = {
'npc_id' => params[:sourceNpcId],
'source_type' => params[:sourceType]
}.compact
@game.add_item_to_room!(room_id, data.to_unsafe_h, source_data)
when 'remove_object'
item_id = data[:id] || data['id'] || data[:itemId] || data['itemId']
unless item_id.present?
return render json: { success: false, message: 'Missing item id' }, status: :bad_request
end
@game.remove_item_from_room!(room_id, item_id)
when 'update_object_state'
object_id = data[:objectId] || data['objectId']
state_changes = data[:stateChanges] || data['stateChanges']
unless object_id.present? && state_changes.present?
return render json: { success: false, message: 'Invalid object state data' }, status: :bad_request
end
@game.update_object_state!(room_id, object_id, state_changes.to_unsafe_h)
when 'move_npc'
npc_id = data[:npcId] || data['npcId']
from_room = data[:fromRoom] || data['fromRoom']
to_room = data[:toRoom] || data['toRoom']
unless npc_id.present? && from_room.present? && to_room.present?
return render json: { success: false, message: 'Invalid NPC move data' }, status: :bad_request
end
@game.move_npc_to_room!(npc_id, from_room, to_room)
else
return render json: { success: false, message: "Unknown action: #{action_type}" }, status: :bad_request
end
if success
render json: { success: true }
else
render json: { success: false, message: 'Failed to update room state' }, status: :unprocessable_entity
end
end
# POST /games/:id/unlock
# Validate unlock attempt
def unlock

View File

@@ -167,6 +167,186 @@ module BreakEscape
save!
end
# ==========================================
# Dynamic Room State Management
# ==========================================
# Add an item to a room (e.g., NPC drops item)
def add_item_to_room!(room_id, item, source_data = {})
player_state['room_states'] ||= {}
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
# Validate item has required fields
unless item.is_a?(Hash) && item['type'].present?
Rails.logger.error "[BreakEscape] Invalid item for add_item_to_room: #{item.inspect}"
return false
end
# Validate source if provided
if source_data['npc_id'].present?
# Verify NPC exists in scenario and is in this room
npc_in_room = npc_in_room?(source_data['npc_id'], room_id)
unless npc_in_room
Rails.logger.warn "[BreakEscape] NPC #{source_data['npc_id']} not in room #{room_id}, rejecting item add"
return false
end
end
# Generate unique ID if not provided
item['id'] ||= "#{room_id}_added_#{SecureRandom.hex(4)}"
# Add to room state
player_state['room_states'][room_id]['objects_added'] << item
save!
Rails.logger.info "[BreakEscape] Added item #{item['type']} (#{item['id']}) to room #{room_id}"
true
end
# Remove an item from a room (e.g., player picks up)
def remove_item_from_room!(room_id, item_id)
player_state['room_states'] ||= {}
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
# Check if item exists in room (scenario or added)
item_exists = item_in_room?(room_id, item_id)
unless item_exists
Rails.logger.warn "[BreakEscape] Item #{item_id} not found in room #{room_id}"
return false
end
# If item was previously added (in objects_added), remove it from there
player_state['room_states'][room_id]['objects_added'].reject! { |obj| obj['id'] == item_id }
# Otherwise, add to objects_removed list
unless player_state['room_states'][room_id]['objects_removed'].include?(item_id)
player_state['room_states'][room_id]['objects_removed'] << item_id
end
save!
Rails.logger.info "[BreakEscape] Removed item #{item_id} from room #{room_id}"
true
end
# Update object state (e.g., container opened, light switched on)
def update_object_state!(room_id, object_id, state_changes)
player_state['room_states'] ||= {}
player_state['room_states'][room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {} }
# Validate object exists
unless item_in_room?(room_id, object_id)
Rails.logger.warn "[BreakEscape] Object #{object_id} not found in room #{room_id}"
return false
end
# Merge state changes
player_state['room_states'][room_id]['object_states'][object_id] ||= {}
player_state['room_states'][room_id]['object_states'][object_id].merge!(state_changes)
save!
Rails.logger.info "[BreakEscape] Updated object #{object_id} state in room #{room_id}: #{state_changes.inspect}"
true
end
# Move NPC between rooms
def move_npc_to_room!(npc_id, from_room_id, to_room_id)
player_state['room_states'] ||= {}
# Validate rooms exist and are connected (or NPC is phone-type that can teleport)
unless rooms_connected?(from_room_id, to_room_id)
# Check if NPC is phone-type (can be anywhere)
npc_data = find_npc_in_scenario(npc_id)
if npc_data && npc_data['npcType'] == 'phone'
# Phone NPCs can "move" freely (they're not physical)
Rails.logger.info "[BreakEscape] Phone NPC #{npc_id} can move freely"
else
Rails.logger.warn "[BreakEscape] Rooms #{from_room_id} and #{to_room_id} not connected, rejecting NPC move"
return false
end
end
# Remove NPC from source room
player_state['room_states'][from_room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npcs_removed' => [] }
player_state['room_states'][from_room_id]['npcs_removed'] ||= []
player_state['room_states'][from_room_id]['npcs_removed'] << npc_id unless player_state['room_states'][from_room_id]['npcs_removed'].include?(npc_id)
# Add NPC to target room
player_state['room_states'][to_room_id] ||= { 'objects_added' => [], 'objects_removed' => [], 'object_states' => {}, 'npcs_added' => [] }
player_state['room_states'][to_room_id]['npcs_added'] ||= []
# Store full NPC data in target room
npc_data = find_npc_in_scenario(npc_id)
if npc_data
npc_with_new_room = npc_data.merge('roomId' => to_room_id)
player_state['room_states'][to_room_id]['npcs_added'] << npc_with_new_room
end
save!
Rails.logger.info "[BreakEscape] Moved NPC #{npc_id} from #{from_room_id} to #{to_room_id}"
true
end
private
# Check if NPC exists in a room (scenario or moved)
def npc_in_room?(npc_id, room_id)
# Check scenario data
room = scenario_data.dig('rooms', room_id)
return false unless room
scenario_has_npc = room['npcs']&.any? { |npc| npc['id'] == npc_id }
# Check if NPC was removed from this room
removed = player_state.dig('room_states', room_id, 'npcs_removed')&.include?(npc_id)
# Check if NPC was added to this room
added = player_state.dig('room_states', room_id, 'npcs_added')&.any? { |npc| npc['id'] == npc_id }
(scenario_has_npc && !removed) || added
end
# Check if item exists in a room
def item_in_room?(room_id, item_id)
room = scenario_data.dig('rooms', room_id)
return false unless room
# Check scenario objects
scenario_has_item = room['objects']&.any? { |obj| obj['id'] == item_id }
# Check added objects
added = player_state.dig('room_states', room_id, 'objects_added')&.any? { |obj| obj['id'] == item_id }
# Check if removed
removed = player_state.dig('room_states', room_id, 'objects_removed')&.include?(item_id)
(scenario_has_item || added) && !removed
end
# Check if two rooms are connected
def rooms_connected?(room1_id, room2_id)
room1 = scenario_data.dig('rooms', room1_id)
room2 = scenario_data.dig('rooms', room2_id)
return false unless room1 && room2
# Check if room1 has connection to room2
room1_connections = room1['connections']&.values || []
room2_connections = room2['connections']&.values || []
room1_connections.include?(room2_id) || room2_connections.include?(room1_id)
end
# Find NPC in scenario data
def find_npc_in_scenario(npc_id)
scenario_data['rooms']&.each do |_room_id, room|
npc = room['npcs']&.find { |n| n['id'] == npc_id }
return npc if npc
end
nil
end
public
# Health management
def update_health!(value)
player_state['health'] = value.clamp(0, 100)
@@ -205,6 +385,9 @@ module BreakEscape
def filtered_room_data(room_id)
room = room_data(room_id)&.deep_dup
return nil unless room
# Apply dynamic room state changes (delta overlay)
apply_room_state_changes!(room, room_id)
# Remove ONLY the 'requires' field (the solution) and locked 'contents'
# Keep lockType, locked, observations visible to client
@@ -212,6 +395,44 @@ module BreakEscape
room
end
# Apply room_states delta to room data
def apply_room_state_changes!(room, room_id)
return unless player_state['room_states']&.key?(room_id)
room_state = player_state['room_states'][room_id]
# Apply object removals
if room_state['objects_removed'].present?
room['objects']&.reject! { |obj| room_state['objects_removed'].include?(obj['id']) }
end
# Apply object additions
if room_state['objects_added'].present?
room['objects'] ||= []
room['objects'].concat(room_state['objects_added'])
end
# Apply object state changes
if room_state['object_states'].present?
room['objects']&.each do |obj|
if room_state['object_states'][obj['id']]
obj.merge!(room_state['object_states'][obj['id']])
end
end
end
# Apply NPC removals
if room_state['npcs_removed'].present?
room['npcs']&.reject! { |npc| room_state['npcs_removed'].include?(npc['id']) }
end
# Apply NPC additions
if room_state['npcs_added'].present?
room['npcs'] ||= []
room['npcs'].concat(room_state['npcs_added'])
end
end
# Unlock validation
def validate_unlock(target_type, target_id, attempt, method)
@@ -714,6 +935,9 @@ module BreakEscape
self.player_state['submitted_flags'] ||= [] # Array of submitted flag strings
self.player_state['flag_rewards_claimed'] ||= [] # Track claimed rewards
self.player_state['pending_events'] ||= [] # Events to emit on next sync
# Dynamic room state tracking (delta overlay on scenario_data)
self.player_state['room_states'] ||= {} # Hash of room modifications
end
def set_started_at

View File

@@ -24,6 +24,10 @@ module BreakEscape
def sync_state?
show?
end
def update_room?
show?
end
def unlock?
show?