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?

View File

@@ -27,6 +27,7 @@ BreakEscape::Engine.routes.draw do
# Game state and actions
put 'sync_state' # Periodic state sync
post 'update_room' # Update dynamic room state (items, NPCs, object states)
post 'unlock' # Validate unlock attempt
post 'inventory' # Update inventory

262
docs/ROOM_STATE_SYNC.md Normal file
View File

@@ -0,0 +1,262 @@
# Room State Sync System
## Overview
The Room State Sync system tracks dynamic changes to rooms during gameplay, ensuring that state persists across page reloads and sessions. This prevents the "feels like starting over" problem when resuming a game.
## Architecture
### Server-Side (Delta Overlay Pattern)
**Storage:** `player_state['room_states']` JSONB column stores deltas on top of `scenario_data`
```ruby
player_state['room_states'] = {
'office1' => {
'objects_added' => [ # Items dropped by NPCs, spawned dynamically
{ 'id' => 'dropped_key_001', 'type' => 'key', 'name' => 'Office Key', ... }
],
'objects_removed' => ['office1_desk_0'], # Items taken by player
'object_states' => { # State changes for existing objects
'office1_cabinet_0' => { 'opened' => true }
},
'npcs_removed' => ['agent_handler'], # NPCs that left the room
'npcs_added' => [ # NPCs that moved into the room
{ 'id' => 'agent_handler', 'roomId' => 'office1', ... }
]
}
}
```
**Merge Logic:** When loading a room, server:
1. Starts with `scenario_data['rooms'][room_id]` (static template)
2. Applies `player_state['room_states'][room_id]` delta
3. Returns merged result to client
**Validation:** All changes are validated server-side:
- Items can only be added if source (NPC) has them
- Objects can only be removed if they exist in the room
- NPCs can only move to connected rooms (or phone-type NPCs)
- Prevents client from spawning arbitrary items
### Client-Side (Sync API)
**Module:** `public/break_escape/js/systems/room-state-sync.js`
**Global API:** `window.RoomStateSync`
## Usage Examples
### 1. NPC Drops an Item
```javascript
// In NPC interaction handler or Ink script result
const item = {
id: 'office_key_dropped',
type: 'key',
name: 'Office Key',
scenarioData: {
key_id: 'office_key',
takeable: true
},
x: npc.x + 50, // Position near NPC
y: npc.y
};
await window.RoomStateSync.addItemToRoom(
'office1',
item,
{ npcId: 'agent_handler', sourceType: 'npc_drop' }
);
// Item is now persisted on server and will appear on reload
```
### 2. Player Picks Up an Item
```javascript
// In inventory system when item is collected
const itemId = 'office1_table_key_0';
const currentRoom = window.currentPlayerRoom;
await window.RoomStateSync.removeItemFromRoom(currentRoom, itemId);
// Item removed from room permanently (won't reappear on reload)
```
### 3. Container State Change
```javascript
// When container is unlocked/opened
await window.RoomStateSync.updateObjectState(
'office1',
'office1_cabinet_0',
{ opened: true, locked: false }
);
// Container will remain open on reload
```
### 4. NPC Moves Between Rooms
```javascript
// When NPC walks through a door or teleports
await window.RoomStateSync.moveNpcToRoom(
'agent_handler',
'safehouse_main',
'safehouse_bedroom'
);
// NPC will appear in new room on reload, not spawn room
```
### 5. Batch Updates
```javascript
// For efficiency when multiple changes happen together
await window.RoomStateSync.batchUpdateRoomState([
{ type: 'add_item', roomId: 'office1', item: keyItem, options: { npcId: 'handler' } },
{ type: 'remove_item', roomId: 'office1', itemId: 'office1_desk_0' },
{ type: 'update_object', roomId: 'office1', objectId: 'safe', stateChanges: { opened: true } }
]);
```
## Integration Points
### Inventory System
**When collecting items:**
```javascript
// In systems/inventory.js
if (item.scenarioData?.takeable) {
await window.RoomStateSync.removeItemFromRoom(
window.currentPlayerRoom,
item.getAttribute('data-id')
);
}
```
### NPC Item Giving
**When NPC gives item to player:**
```javascript
// In minigames/person-chat/person-chat-conversation.js
// After item is added to player inventory, optionally track NPC's inventory change
// (if you want to prevent NPC from giving the same item twice)
```
### Container Minigame
**When container is unlocked:**
```javascript
// In minigames/container/container-minigame.js
if (unlocked) {
await window.RoomStateSync.updateObjectState(
roomId,
containerId,
{ opened: true, locked: false }
);
}
```
### NPC Movement System
**When NPC changes rooms:**
```javascript
// In systems/npc-behavior.js or via Ink script commands
await window.RoomStateSync.moveNpcToRoom(
npcId,
oldRoomId,
newRoomId
);
```
## Server Validation Logic
### `add_item_to_room!`
- Validates item has required fields (type, etc.)
- Checks NPC source exists in the room (if specified)
- Generates unique ID if not provided
- Adds to `room_states[room_id]['objects_added']`
### `remove_item_from_room!`
- Validates item exists in room (scenario or added)
- Removes from `objects_added` if dynamically added
- Otherwise adds to `objects_removed` list
- Returns false if item not found
### `update_object_state!`
- Validates object exists in room
- Merges state changes into `object_states[object_id]`
- Preserves existing state properties
### `move_npc_to_room!`
- Validates rooms are connected OR NPC is phone-type
- Adds NPC ID to source room's `npcs_removed`
- Adds full NPC data to target room's `npcs_added`
- Returns false if rooms not connected (for sprite NPCs)
## Benefits
**Items dropped by NPCs persist** - Key remains on floor after NPC drops it
**Containers stay opened** - No need to re-unlock after reload
**NPCs remember positions** - Don't teleport back to spawn points
**Collected items stay gone** - Room doesn't refill after reload
**Validated server-side** - Client cannot spawn arbitrary items
**Minimal storage** - Delta approach only stores changes
**No migration needed** - Uses existing JSONB column
## Testing
### Manual Testing
1. Start a game, enter a room
2. Have an NPC drop an item via Ink script
3. Reload the page
4. Item should still be on the ground
### Server Validation Testing
Try to exploit the system (should all fail):
- Send `add_object` with invalid item data → 400 Bad Request
- Send `remove_object` for item not in room → 422 Unprocessable Entity
- Send `move_npc` for non-connected rooms → 422 Unprocessable Entity
- Send `update_room` for locked room → 403 Forbidden
### Example Test Scenario
```javascript
// In browser console after game loads:
// 1. Add item (should succeed)
await window.RoomStateSync.addItemToRoom('office1', {
type: 'key',
name: 'Test Key',
id: 'test_key_001'
});
// 2. Reload page, check room data
await window.loadRoom('office1');
console.log(window.rooms['office1'].objects);
// Should include test_key_001
// 3. Remove item (should succeed)
await window.RoomStateSync.removeItemFromRoom('office1', 'test_key_001');
// 4. Reload page, check again
// test_key_001 should be gone
```
## Limitations
- **NPC conversation states** not tracked (use NPC manager's conversation history)
- **Temporary visual effects** not persisted (by design - only game state)
- **Player position** not tracked here (use `player_state['currentRoom']` instead)
- **Quest/objective state** tracked separately in `objectivesState`
## Future Enhancements
- Add `undo` capability for accidental room state changes
- Track more granular object properties (rotation, position, etc.)
- Add visual indicators for modified rooms in UI
- Export room state history for debugging

View File

@@ -28,6 +28,9 @@ import { getObjectivesManager } from './systems/objectives-manager.js?v=1';
// Import Tutorial System
import { getTutorialManager } from './systems/tutorial-manager.js';
// Import Room State Sync System
import './systems/room-state-sync.js';
// Global game variables
window.game = null;
window.gameScenario = null;

View File

@@ -401,7 +401,7 @@ export async function addToInventory(sprite) {
}
}
// Remove from room if it exists
// Remove from room if it exists and sync with server
if (window.currentPlayerRoom && rooms[window.currentPlayerRoom] && rooms[window.currentPlayerRoom].objects) {
if (rooms[window.currentPlayerRoom].objects[sprite.objectId]) {
const roomObj = rooms[window.currentPlayerRoom].objects[sprite.objectId];
@@ -410,6 +410,14 @@ export async function addToInventory(sprite) {
}
roomObj.active = false;
console.log(`Removed object ${sprite.objectId} from room`);
// Sync object removal with server's canonical room JSON
if (window.RoomStateSync) {
window.RoomStateSync.removeItemFromRoom(window.currentPlayerRoom, sprite.objectId).catch(err => {
console.error('Failed to sync object removal to server:', err);
// Don't fail the pickup - local state is already updated
});
}
}
}

View File

@@ -98,9 +98,11 @@ function damageNPC(npcId, amount) {
if (state.currentHP <= 0) {
state.isKO = true;
// Play death animation and disable physics after it completes
// Get NPC reference for death animation and server sync
const npc = window.npcManager?.getNPC(npcId);
const sprite = npc?._sprite || npc?.sprite;
// Play death animation and disable physics after it completes
if (sprite) {
// Disable collisions immediately so player can walk through
if (sprite.body) {
@@ -126,6 +128,8 @@ function damageNPC(npcId, amount) {
// Drop any items the NPC was holding
dropNPCItems(npcId);
// Note: Item drops are synced to server in dropNPCItems via RoomStateSync
if (window.eventDispatcher) {
window.eventDispatcher.emit(CombatEvents.NPC_KO, { npcId });
}
@@ -368,6 +372,29 @@ function dropNPCItems(npcId) {
room.objects[spriteObj.objectId] = spriteObj;
console.log(`💧 Dropped item ${droppedItemData.type} from ${npcId} at (${spawnX}, ${spawnY}), launching at angle ${(angle * 180 / Math.PI).toFixed(1)}°`);
// Sync dropped item to server for persistence
if (window.RoomStateSync) {
// Create item data for server (without Phaser-specific properties)
const itemForServer = {
id: spriteObj.objectId,
type: droppedItemData.type,
name: droppedItemData.name,
texture: texture,
x: spawnX,
y: spawnY,
takeable: true,
interactable: true,
scenarioData: droppedItemData
};
window.RoomStateSync.addItemToRoom(npcRoomId, itemForServer, {
npcId: npcId,
sourceType: 'npc_defeated'
}).catch(err => {
console.error('Failed to sync dropped item to server:', err);
});
}
});
// Clear the NPC's inventory

View File

@@ -0,0 +1,279 @@
/**
* Room State Sync System
*
* Syncs dynamic room state changes to the server for persistence across sessions.
* Tracks items added/removed, NPC movements, and object state changes.
*
* All changes are validated server-side to prevent cheating.
*/
/**
* Add an item to a room (e.g., NPC drops an item)
* @param {string} roomId - Room ID
* @param {object} item - Item data (must include type, id, name, etc.)
* @param {object} options - Optional source data (npcId, sourceType)
* @returns {Promise<boolean>} Success status
*/
export async function addItemToRoom(roomId, item, options = {}) {
const gameId = window.breakEscapeConfig?.gameId;
if (!gameId) {
console.error('Cannot sync room state: gameId not available');
return false;
}
try {
const response = await fetch(`/break_escape/games/${gameId}/update_room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({
roomId: roomId,
actionType: 'add_object',
data: item,
sourceNpcId: options.npcId,
sourceType: options.sourceType
})
});
const result = await response.json();
if (result.success) {
console.log(`✅ Synced item add to room ${roomId}:`, item.type);
// Update local room state if room is loaded
if (window.rooms && window.rooms[roomId]) {
window.rooms[roomId].objects = window.rooms[roomId].objects || {};
window.rooms[roomId].objects[item.id] = item;
}
return true;
} else {
console.warn(`❌ Failed to sync item add: ${result.message}`);
return false;
}
} catch (error) {
console.error('Error syncing item add to room:', error);
return false;
}
}
/**
* Remove an item from a room (e.g., player picks up)
* @param {string} roomId - Room ID
* @param {string} itemId - Item ID to remove
* @returns {Promise<boolean>} Success status
*/
export async function removeItemFromRoom(roomId, itemId) {
const gameId = window.breakEscapeConfig?.gameId;
if (!gameId) {
console.error('Cannot sync room state: gameId not available');
return false;
}
try {
const response = await fetch(`/break_escape/games/${gameId}/update_room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({
roomId: roomId,
actionType: 'remove_object',
data: { id: itemId }
})
});
const result = await response.json();
if (result.success) {
console.log(`✅ Synced item removal from room ${roomId}: ${itemId}`);
// Update local room state if room is loaded
if (window.rooms && window.rooms[roomId] && window.rooms[roomId].objects) {
delete window.rooms[roomId].objects[itemId];
}
return true;
} else {
console.warn(`❌ Failed to sync item removal: ${result.message}`);
return false;
}
} catch (error) {
console.error('Error syncing item removal from room:', error);
return false;
}
}
/**
* Update object state in a room (e.g., container opened, light switched)
* @param {string} roomId - Room ID
* @param {string} objectId - Object ID
* @param {object} stateChanges - State properties to update
* @returns {Promise<boolean>} Success status
*/
export async function updateObjectState(roomId, objectId, stateChanges) {
const gameId = window.breakEscapeConfig?.gameId;
if (!gameId) {
console.error('Cannot sync room state: gameId not available');
return false;
}
try {
const response = await fetch(`/break_escape/games/${gameId}/update_room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({
roomId: roomId,
actionType: 'update_object_state',
data: {
objectId: objectId,
stateChanges: stateChanges
}
})
});
const result = await response.json();
if (result.success) {
console.log(`✅ Synced object state update in room ${roomId}:`, objectId, stateChanges);
// Update local room state if room is loaded
if (window.rooms && window.rooms[roomId] && window.rooms[roomId].objects) {
const obj = window.rooms[roomId].objects[objectId];
if (obj) {
Object.assign(obj, stateChanges);
}
}
return true;
} else {
console.warn(`❌ Failed to sync object state: ${result.message}`);
return false;
}
} catch (error) {
console.error('Error syncing object state:', error);
return false;
}
}
/**
* Move NPC between rooms
* @param {string} npcId - NPC ID
* @param {string} fromRoomId - Source room ID
* @param {string} toRoomId - Target room ID
* @returns {Promise<boolean>} Success status
*/
export async function moveNpcToRoom(npcId, fromRoomId, toRoomId) {
const gameId = window.breakEscapeConfig?.gameId;
if (!gameId) {
console.error('Cannot sync room state: gameId not available');
return false;
}
try {
const response = await fetch(`/break_escape/games/${gameId}/update_room`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({
roomId: toRoomId, // Target room
actionType: 'move_npc',
data: {
npcId: npcId,
fromRoom: fromRoomId,
toRoom: toRoomId
}
})
});
const result = await response.json();
if (result.success) {
console.log(`✅ Synced NPC move: ${npcId} from ${fromRoomId} to ${toRoomId}`);
// Update local room state if rooms are loaded
if (window.rooms) {
// Remove from source room
if (window.rooms[fromRoomId]) {
window.rooms[fromRoomId].npcs = window.rooms[fromRoomId].npcs || [];
window.rooms[fromRoomId].npcs = window.rooms[fromRoomId].npcs.filter(npc => npc.id !== npcId);
}
// Add to target room (need to get NPC data)
if (window.rooms[toRoomId] && window.npcManager) {
const npcData = window.npcManager.getNPC(npcId);
if (npcData) {
window.rooms[toRoomId].npcs = window.rooms[toRoomId].npcs || [];
window.rooms[toRoomId].npcs.push({ ...npcData, roomId: toRoomId });
}
}
}
return true;
} else {
console.warn(`❌ Failed to sync NPC move: ${result.message}`);
return false;
}
} catch (error) {
console.error('Error syncing NPC move:', error);
return false;
}
}
/**
* Batch update room state (for efficiency when multiple changes happen together)
* @param {Array<object>} updates - Array of update operations
* @returns {Promise<boolean>} Success status
*/
export async function batchUpdateRoomState(updates) {
const promises = updates.map(update => {
switch (update.type) {
case 'add_item':
return addItemToRoom(update.roomId, update.item, update.options);
case 'remove_item':
return removeItemFromRoom(update.roomId, update.itemId);
case 'update_object':
return updateObjectState(update.roomId, update.objectId, update.stateChanges);
case 'move_npc':
return moveNpcToRoom(update.npcId, update.fromRoomId, update.toRoomId);
default:
console.warn(`Unknown update type: ${update.type}`);
return Promise.resolve(false);
}
});
try {
const results = await Promise.all(promises);
const allSucceeded = results.every(r => r === true);
if (allSucceeded) {
console.log(`✅ Batch room state update completed: ${updates.length} operations`);
} else {
console.warn(`⚠️ Batch room state update partially failed`);
}
return allSucceeded;
} catch (error) {
console.error('Error in batch room state update:', error);
return false;
}
}
// Export to window for global access
window.RoomStateSync = {
addItemToRoom,
removeItemFromRoom,
updateObjectState,
moveNpcToRoom,
batchUpdateRoomState
};
console.log('✅ Room State Sync system loaded');