mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
feat: Implement dynamic room state management with server synchronization for items, NPCs, and object states
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,10 @@ module BreakEscape
|
||||
def sync_state?
|
||||
show?
|
||||
end
|
||||
|
||||
def update_room?
|
||||
show?
|
||||
end
|
||||
|
||||
def unlock?
|
||||
show?
|
||||
|
||||
@@ -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
262
docs/ROOM_STATE_SYNC.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
279
public/break_escape/js/systems/room-state-sync.js
Normal file
279
public/break_escape/js/systems/room-state-sync.js
Normal 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');
|
||||
Reference in New Issue
Block a user