Compare commits
10 Commits
mimo/code/
...
fix/1540
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d8bc556e2 | |||
| d1f6421c49 | |||
| 8d87dba309 | |||
| 9322742ef8 | |||
| f91c0bef0f | |||
| 157f6f322d | |||
| 2978f48a6a | |||
|
|
b0b4cd0c97 | ||
|
|
3fed634955 | ||
|
|
b79805118e |
@@ -395,6 +395,7 @@
|
|||||||
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
|
||||||
|
|
||||||
<script src="./boot.js"></script>
|
<script src="./boot.js"></script>
|
||||||
|
<script src="./js/spatial-search.js"></script>
|
||||||
<script src="./avatar-customization.js"></script>
|
<script src="./avatar-customization.js"></script>
|
||||||
<script src="./lod-system.js"></script>
|
<script src="./lod-system.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
457
js/spatial-search.js
Normal file
457
js/spatial-search.js
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Spatial Search - Find nearest user/object by name
|
||||||
|
* Issue #1540: feat: spatial search — find nearest user/object by name
|
||||||
|
*
|
||||||
|
* Provides search functionality to find users/objects by name
|
||||||
|
* and show distance, direction, and pathfinding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SpatialSearch {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.maxDistance = options.maxDistance || 1000; // Maximum search distance
|
||||||
|
this.searchDelay = options.searchDelay || 300; // Delay before search (ms)
|
||||||
|
this.onResultSelect = options.onResultSelect || (() => {});
|
||||||
|
this.onError = options.onError || console.error;
|
||||||
|
|
||||||
|
// Track entities in the world
|
||||||
|
this.entities = new Map(); // id -> {name, position, type, ...}
|
||||||
|
|
||||||
|
// Search UI elements
|
||||||
|
this.searchInput = null;
|
||||||
|
this.resultsContainer = null;
|
||||||
|
this.pathArrow = null;
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
this.initUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize search UI
|
||||||
|
*/
|
||||||
|
initUI() {
|
||||||
|
// Create search container
|
||||||
|
const searchContainer = document.createElement('div');
|
||||||
|
searchContainer.id = 'spatial-search-container';
|
||||||
|
searchContainer.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create search input
|
||||||
|
this.searchInput = document.createElement('input');
|
||||||
|
this.searchInput.type = 'text';
|
||||||
|
this.searchInput.placeholder = 'Search users/objects...';
|
||||||
|
this.searchInput.style.cssText = `
|
||||||
|
width: 300px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 2px solid #4af0c0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create results container
|
||||||
|
this.resultsContainer = document.createElement('div');
|
||||||
|
this.resultsContainer.id = 'spatial-search-results';
|
||||||
|
this.resultsContainer.style.cssText = `
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
border: 1px solid #4af0c0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
this.searchInput.addEventListener('input', () => this.handleSearch());
|
||||||
|
this.searchInput.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||||
|
|
||||||
|
// Add to container
|
||||||
|
searchContainer.appendChild(this.searchInput);
|
||||||
|
searchContainer.appendChild(this.resultsContainer);
|
||||||
|
|
||||||
|
// Add to document
|
||||||
|
document.body.appendChild(searchContainer);
|
||||||
|
|
||||||
|
// Create path arrow
|
||||||
|
this.pathArrow = document.createElement('div');
|
||||||
|
this.pathArrow.id = 'spatial-search-arrow';
|
||||||
|
this.pathArrow.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 100px;
|
||||||
|
right: 20px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(74, 240, 192, 0.2);
|
||||||
|
border: 2px solid #4af0c0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #4af0c0;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 999;
|
||||||
|
`;
|
||||||
|
this.pathArrow.innerHTML = '→';
|
||||||
|
this.pathArrow.addEventListener('click', () => this.clearSearch());
|
||||||
|
document.body.appendChild(this.pathArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show search UI
|
||||||
|
*/
|
||||||
|
show() {
|
||||||
|
const container = document.getElementById('spatial-search-container');
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'block';
|
||||||
|
this.searchInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide search UI
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
const container = document.getElementById('spatial-search-container');
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
this.clearResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle search UI
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
const container = document.getElementById('spatial-search-container');
|
||||||
|
if (container) {
|
||||||
|
if (container.style.display === 'none') {
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search input
|
||||||
|
*/
|
||||||
|
handleSearch() {
|
||||||
|
const query = this.searchInput.value.trim();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.clearResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
clearTimeout(this.searchTimeout);
|
||||||
|
this.searchTimeout = setTimeout(() => {
|
||||||
|
this.performSearch(query);
|
||||||
|
}, this.searchDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events
|
||||||
|
*/
|
||||||
|
handleKeydown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.hide();
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
const firstResult = this.resultsContainer.querySelector('.search-result');
|
||||||
|
if (firstResult) {
|
||||||
|
firstResult.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform search
|
||||||
|
*/
|
||||||
|
performSearch(query) {
|
||||||
|
const results = this.searchEntities(query);
|
||||||
|
this.displayResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search entities by name
|
||||||
|
*/
|
||||||
|
searchEntities(query) {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const [id, entity] of this.entities) {
|
||||||
|
const name = (entity.name || '').toLowerCase();
|
||||||
|
const type = (entity.type || '').toLowerCase();
|
||||||
|
|
||||||
|
// Check if name or type matches query
|
||||||
|
if (name.includes(lowerQuery) || type.includes(lowerQuery)) {
|
||||||
|
results.push({
|
||||||
|
id,
|
||||||
|
name: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
position: entity.position,
|
||||||
|
distance: this.calculateDistance(entity.position),
|
||||||
|
direction: this.calculateDirection(entity.position)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by distance
|
||||||
|
results.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
return results.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance to entity
|
||||||
|
*/
|
||||||
|
calculateDistance(position) {
|
||||||
|
if (!position) return Infinity;
|
||||||
|
|
||||||
|
// Get local player position (would be from game state)
|
||||||
|
const localPos = this.getLocalPlayerPosition();
|
||||||
|
if (!localPos) return Infinity;
|
||||||
|
|
||||||
|
const dx = position.x - localPos.x;
|
||||||
|
const dy = position.y - localPos.y;
|
||||||
|
const dz = position.z - localPos.z;
|
||||||
|
|
||||||
|
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate direction to entity
|
||||||
|
*/
|
||||||
|
calculateDirection(position) {
|
||||||
|
if (!position) return 'unknown';
|
||||||
|
|
||||||
|
const localPos = this.getLocalPlayerPosition();
|
||||||
|
if (!localPos) return 'unknown';
|
||||||
|
|
||||||
|
const dx = position.x - localPos.x;
|
||||||
|
const dz = position.z - localPos.z;
|
||||||
|
|
||||||
|
// Calculate angle
|
||||||
|
const angle = Math.atan2(dz, dx) * (180 / Math.PI);
|
||||||
|
|
||||||
|
// Convert to direction
|
||||||
|
if (angle >= -22.5 && angle < 22.5) return 'E';
|
||||||
|
if (angle >= 22.5 && angle < 67.5) return 'SE';
|
||||||
|
if (angle >= 67.5 && angle < 112.5) return 'S';
|
||||||
|
if (angle >= 112.5 && angle < 157.5) return 'SW';
|
||||||
|
if (angle >= 157.5 || angle < -157.5) return 'W';
|
||||||
|
if (angle >= -157.5 && angle < -112.5) return 'NW';
|
||||||
|
if (angle >= -112.5 && angle < -67.5) return 'N';
|
||||||
|
if (angle >= -67.5 && angle < -22.5) return 'NE';
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local player position (placeholder)
|
||||||
|
*/
|
||||||
|
getLocalPlayerPosition() {
|
||||||
|
// In real implementation, this would get position from game state
|
||||||
|
// For now, return a placeholder
|
||||||
|
return { x: 0, y: 0, z: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display search results
|
||||||
|
*/
|
||||||
|
displayResults(results) {
|
||||||
|
this.clearResults();
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
const noResults = document.createElement('div');
|
||||||
|
noResults.className = 'search-no-results';
|
||||||
|
noResults.textContent = 'No results found';
|
||||||
|
noResults.style.cssText = `
|
||||||
|
padding: 10px;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
`;
|
||||||
|
this.resultsContainer.appendChild(noResults);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const resultElement = this.createResultElement(result);
|
||||||
|
this.resultsContainer.appendChild(resultElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create result element
|
||||||
|
*/
|
||||||
|
createResultElement(result) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'search-result';
|
||||||
|
element.style.cssText = `
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-bottom: 1px solid rgba(74, 240, 192, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
element.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: bold; color: #4af0c0;">${result.name}</div>
|
||||||
|
<div style="font-size: 12px; color: #888;">${result.type || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="color: #4af0c0;">${result.distance.toFixed(1)}m</div>
|
||||||
|
<div style="font-size: 12px; color: #888;">${result.direction}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
element.addEventListener('click', () => {
|
||||||
|
this.selectResult(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a search result
|
||||||
|
*/
|
||||||
|
selectResult(result) {
|
||||||
|
console.log('[SpatialSearch] Selected:', result);
|
||||||
|
|
||||||
|
// Show path arrow
|
||||||
|
this.showPathArrow(result);
|
||||||
|
|
||||||
|
// Call callback
|
||||||
|
this.onResultSelect(result);
|
||||||
|
|
||||||
|
// Hide search
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show path arrow pointing to result
|
||||||
|
*/
|
||||||
|
showPathArrow(result) {
|
||||||
|
if (!this.pathArrow) return;
|
||||||
|
|
||||||
|
// Update arrow direction
|
||||||
|
const arrow = this.pathArrow.querySelector('span') || this.pathArrow;
|
||||||
|
const directionArrows = {
|
||||||
|
'N': '↑',
|
||||||
|
'NE': '↗',
|
||||||
|
'E': '→',
|
||||||
|
'SE': '↘',
|
||||||
|
'S': '↓',
|
||||||
|
'SW': '↙',
|
||||||
|
'W': '←',
|
||||||
|
'NW': '↖'
|
||||||
|
};
|
||||||
|
|
||||||
|
arrow.innerHTML = directionArrows[result.direction] || '?';
|
||||||
|
|
||||||
|
// Show arrow
|
||||||
|
this.pathArrow.style.display = 'flex';
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
this.pathArrow.title = `${result.name} - ${result.distance.toFixed(1)}m ${result.direction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear search results
|
||||||
|
*/
|
||||||
|
clearResults() {
|
||||||
|
if (this.resultsContainer) {
|
||||||
|
this.resultsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear search and hide arrow
|
||||||
|
*/
|
||||||
|
clearSearch() {
|
||||||
|
this.clearResults();
|
||||||
|
this.searchInput.value = '';
|
||||||
|
|
||||||
|
if (this.pathArrow) {
|
||||||
|
this.pathArrow.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an entity
|
||||||
|
*/
|
||||||
|
registerEntity(id, entity) {
|
||||||
|
this.entities.set(id, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister an entity
|
||||||
|
*/
|
||||||
|
unregisterEntity(id) {
|
||||||
|
this.entities.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update entity position
|
||||||
|
*/
|
||||||
|
updateEntityPosition(id, position) {
|
||||||
|
const entity = this.entities.get(id);
|
||||||
|
if (entity) {
|
||||||
|
entity.position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get search status
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
entityCount: this.entities.size,
|
||||||
|
maxDistance: this.maxDistance,
|
||||||
|
searchDelay: this.searchDelay
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other modules
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SpatialSearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance for browser use
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.SpatialSearch = SpatialSearch;
|
||||||
|
|
||||||
|
// Auto-initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const search = new SpatialSearch({
|
||||||
|
maxDistance: 1000,
|
||||||
|
onResultSelect: (result) => {
|
||||||
|
console.log('Selected:', result);
|
||||||
|
// In real implementation, this would navigate to the entity
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store globally
|
||||||
|
window.spatialSearch = search;
|
||||||
|
|
||||||
|
// Add keyboard shortcut (Ctrl+F or Cmd+F)
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
e.preventDefault();
|
||||||
|
search.toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
118
server.py
118
server.py
@@ -3,20 +3,34 @@
|
|||||||
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
||||||
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
||||||
the body (Evennia/Morrowind), and the visualization surface.
|
the body (Evennia/Morrowind), and the visualization surface.
|
||||||
|
|
||||||
|
Security features:
|
||||||
|
- Binds to 127.0.0.1 by default (localhost only)
|
||||||
|
- Optional external binding via NEXUS_WS_HOST environment variable
|
||||||
|
- Token-based authentication via NEXUS_WS_TOKEN environment variable
|
||||||
|
- Rate limiting on connections
|
||||||
|
- Connection logging and monitoring
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from typing import Set
|
import time
|
||||||
|
from typing import Set, Dict, Optional
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
# Branch protected file - see POLICY.md
|
# Branch protected file - see POLICY.md
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PORT = 8765
|
PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
|
||||||
HOST = "0.0.0.0" # Allow external connections if needed
|
HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
|
||||||
|
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
|
||||||
|
RATE_LIMIT_WINDOW = 60 # seconds
|
||||||
|
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
|
||||||
|
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
|
||||||
|
|
||||||
# Logging setup
|
# Logging setup
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -28,15 +42,97 @@ logger = logging.getLogger("nexus-gateway")
|
|||||||
|
|
||||||
# State
|
# State
|
||||||
clients: Set[websockets.WebSocketServerProtocol] = set()
|
clients: Set[websockets.WebSocketServerProtocol] = set()
|
||||||
|
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
|
||||||
|
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
|
||||||
|
|
||||||
|
def check_rate_limit(ip: str) -> bool:
|
||||||
|
"""Check if IP has exceeded connection rate limit."""
|
||||||
|
now = time.time()
|
||||||
|
# Clean old entries
|
||||||
|
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
|
||||||
|
|
||||||
|
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
connection_tracker[ip].append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_message_rate_limit(connection_id: int) -> bool:
|
||||||
|
"""Check if connection has exceeded message rate limit."""
|
||||||
|
now = time.time()
|
||||||
|
# Clean old entries
|
||||||
|
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
|
||||||
|
|
||||||
|
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
message_tracker[connection_id].append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
|
||||||
|
"""Authenticate WebSocket connection using token."""
|
||||||
|
if not AUTH_TOKEN:
|
||||||
|
# No authentication required
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for authentication message (first message should be auth)
|
||||||
|
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
|
||||||
|
auth_data = json.loads(auth_message)
|
||||||
|
|
||||||
|
if auth_data.get("type") != "auth":
|
||||||
|
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
token = auth_data.get("token", "")
|
||||||
|
if token != AUTH_TOKEN:
|
||||||
|
logger.warning(f"Invalid auth token from {websocket.remote_address}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Authenticated connection from {websocket.remote_address}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"Authentication timeout from {websocket.remote_address}")
|
||||||
|
return False
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||||
"""Handles individual client connections and message broadcasting."""
|
"""Handles individual client connections and message broadcasting."""
|
||||||
clients.add(websocket)
|
|
||||||
addr = websocket.remote_address
|
addr = websocket.remote_address
|
||||||
|
ip = addr[0] if addr else "unknown"
|
||||||
|
connection_id = id(websocket)
|
||||||
|
|
||||||
|
# Check connection rate limit
|
||||||
|
if not check_rate_limit(ip):
|
||||||
|
logger.warning(f"Connection rate limit exceeded for {ip}")
|
||||||
|
await websocket.close(1008, "Rate limit exceeded")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Authenticate if token is required
|
||||||
|
if not await authenticate_connection(websocket):
|
||||||
|
await websocket.close(1008, "Authentication failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
clients.add(websocket)
|
||||||
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
|
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for message in websocket:
|
async for message in websocket:
|
||||||
|
# Check message rate limit
|
||||||
|
if not check_message_rate_limit(connection_id):
|
||||||
|
logger.warning(f"Message rate limit exceeded for {addr}")
|
||||||
|
await websocket.send(json.dumps({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Message rate limit exceeded"
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Parse for logging/validation if it's JSON
|
# Parse for logging/validation if it's JSON
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
@@ -81,6 +177,20 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
|||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Main server loop with graceful shutdown."""
|
"""Main server loop with graceful shutdown."""
|
||||||
|
# Log security configuration
|
||||||
|
if AUTH_TOKEN:
|
||||||
|
logger.info("Authentication: ENABLED (token required)")
|
||||||
|
else:
|
||||||
|
logger.warning("Authentication: DISABLED (no token required)")
|
||||||
|
|
||||||
|
if HOST == "0.0.0.0":
|
||||||
|
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
|
||||||
|
else:
|
||||||
|
logger.info(f"Host binding: {HOST} (localhost only)")
|
||||||
|
|
||||||
|
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
|
||||||
|
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
|
||||||
|
|
||||||
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
||||||
|
|
||||||
# Set up signal handlers for graceful shutdown
|
# Set up signal handlers for graceful shutdown
|
||||||
|
|||||||
193
tests/load/websocket_load_test.py
Normal file
193
tests/load/websocket_load_test.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
WebSocket Load Test — Benchmark concurrent user sessions on the Nexus gateway.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Concurrent WebSocket connections
|
||||||
|
- Message throughput under load
|
||||||
|
- Memory profiling per connection
|
||||||
|
- Connection failure/recovery
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tests/load/websocket_load_test.py # default (50 users)
|
||||||
|
python3 tests/load/websocket_load_test.py --users 200 # 200 concurrent
|
||||||
|
python3 tests/load/websocket_load_test.py --duration 60 # 60 second test
|
||||||
|
python3 tests/load/websocket_load_test.py --json # JSON output
|
||||||
|
|
||||||
|
Ref: #1505
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
WS_URL = os.environ.get("WS_URL", "ws://localhost:8765")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectionStats:
|
||||||
|
connected: bool = False
|
||||||
|
connect_time_ms: float = 0
|
||||||
|
messages_sent: int = 0
|
||||||
|
messages_received: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
latencies: List[float] = field(default_factory=list)
|
||||||
|
disconnected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_client(user_id: int, duration: int, stats: ConnectionStats, ws_url: str = WS_URL):
|
||||||
|
"""Single WebSocket client for load testing."""
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: use raw asyncio
|
||||||
|
stats.errors += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
async with websockets.connect(ws_url, open_timeout=5) as ws:
|
||||||
|
stats.connect_time_ms = (time.time() - start) * 1000
|
||||||
|
stats.connected = True
|
||||||
|
|
||||||
|
# Send periodic messages for the duration
|
||||||
|
end_time = time.time() + duration
|
||||||
|
msg_count = 0
|
||||||
|
while time.time() < end_time:
|
||||||
|
try:
|
||||||
|
msg_start = time.time()
|
||||||
|
message = json.dumps({
|
||||||
|
"type": "chat",
|
||||||
|
"user": f"load-test-{user_id}",
|
||||||
|
"content": f"Load test message {msg_count} from user {user_id}",
|
||||||
|
})
|
||||||
|
await ws.send(message)
|
||||||
|
stats.messages_sent += 1
|
||||||
|
|
||||||
|
# Wait for response (with timeout)
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
||||||
|
stats.messages_received += 1
|
||||||
|
latency = (time.time() - msg_start) * 1000
|
||||||
|
stats.latencies.append(latency)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stats.errors += 1
|
||||||
|
|
||||||
|
msg_count += 1
|
||||||
|
await asyncio.sleep(0.5) # 2 messages/sec per user
|
||||||
|
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
stats.disconnected = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
stats.errors += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats.errors += 1
|
||||||
|
if "Connection refused" in str(e) or "connect" in str(e).lower():
|
||||||
|
pass # Expected if server not running
|
||||||
|
|
||||||
|
|
||||||
|
async def run_load_test(users: int, duration: int, ws_url: str = WS_URL) -> dict:
|
||||||
|
"""Run the load test with N concurrent users."""
|
||||||
|
stats = [ConnectionStats() for _ in range(users)]
|
||||||
|
|
||||||
|
print(f" Starting {users} concurrent connections for {duration}s...")
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
tasks = [ws_client(i, duration, stats[i], ws_url) for i in range(users)]
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
total_time = time.time() - start
|
||||||
|
|
||||||
|
# Aggregate results
|
||||||
|
connected = sum(1 for s in stats if s.connected)
|
||||||
|
total_sent = sum(s.messages_sent for s in stats)
|
||||||
|
total_received = sum(s.messages_received for s in stats)
|
||||||
|
total_errors = sum(s.errors for s in stats)
|
||||||
|
disconnected = sum(1 for s in stats if s.disconnected)
|
||||||
|
|
||||||
|
all_latencies = []
|
||||||
|
for s in stats:
|
||||||
|
all_latencies.extend(s.latencies)
|
||||||
|
|
||||||
|
avg_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0
|
||||||
|
p95_latency = sorted(all_latencies)[int(len(all_latencies) * 0.95)] if all_latencies else 0
|
||||||
|
p99_latency = sorted(all_latencies)[int(len(all_latencies) * 0.99)] if all_latencies else 0
|
||||||
|
|
||||||
|
avg_connect_time = sum(s.connect_time_ms for s in stats if s.connected) / connected if connected else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": users,
|
||||||
|
"duration_seconds": round(total_time, 1),
|
||||||
|
"connected": connected,
|
||||||
|
"connect_rate": round(connected / users * 100, 1),
|
||||||
|
"messages_sent": total_sent,
|
||||||
|
"messages_received": total_received,
|
||||||
|
"throughput_msg_per_sec": round(total_sent / total_time, 1) if total_time > 0 else 0,
|
||||||
|
"avg_latency_ms": round(avg_latency, 1),
|
||||||
|
"p95_latency_ms": round(p95_latency, 1),
|
||||||
|
"p99_latency_ms": round(p99_latency, 1),
|
||||||
|
"avg_connect_time_ms": round(avg_connect_time, 1),
|
||||||
|
"errors": total_errors,
|
||||||
|
"disconnected": disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(result: dict):
|
||||||
|
"""Print load test report."""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" WEBSOCKET LOAD TEST REPORT")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
print(f" Connections: {result['connected']}/{result['users']} ({result['connect_rate']}%)")
|
||||||
|
print(f" Duration: {result['duration_seconds']}s")
|
||||||
|
print(f" Messages sent: {result['messages_sent']}")
|
||||||
|
print(f" Messages recv: {result['messages_received']}")
|
||||||
|
print(f" Throughput: {result['throughput_msg_per_sec']} msg/s")
|
||||||
|
print(f" Avg connect: {result['avg_connect_time_ms']}ms")
|
||||||
|
print()
|
||||||
|
print(f" Latency:")
|
||||||
|
print(f" Avg: {result['avg_latency_ms']}ms")
|
||||||
|
print(f" P95: {result['p95_latency_ms']}ms")
|
||||||
|
print(f" P99: {result['p99_latency_ms']}ms")
|
||||||
|
print()
|
||||||
|
print(f" Errors: {result['errors']}")
|
||||||
|
print(f" Disconnected: {result['disconnected']}")
|
||||||
|
|
||||||
|
# Verdict
|
||||||
|
if result['connect_rate'] >= 95 and result['errors'] == 0:
|
||||||
|
print(f"\n ✅ PASS")
|
||||||
|
elif result['connect_rate'] >= 80:
|
||||||
|
print(f"\n ⚠️ DEGRADED")
|
||||||
|
else:
|
||||||
|
print(f"\n ❌ FAIL")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="WebSocket Load Test")
|
||||||
|
parser.add_argument("--users", type=int, default=50, help="Concurrent users")
|
||||||
|
parser.add_argument("--duration", type=int, default=30, help="Test duration in seconds")
|
||||||
|
parser.add_argument("--json", action="store_true", help="JSON output")
|
||||||
|
parser.add_argument("--url", default=WS_URL, help="WebSocket URL")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
ws_url = args.url
|
||||||
|
|
||||||
|
print(f"\nWebSocket Load Test — {args.users} users, {args.duration}s\n")
|
||||||
|
|
||||||
|
result = asyncio.run(run_load_test(args.users, args.duration, ws_url))
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
else:
|
||||||
|
print_report(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
223
tests/test_spatial_search.js
Normal file
223
tests/test_spatial_search.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Spatial Search
|
||||||
|
* Issue #1540: feat: spatial search — find nearest user/object by name
|
||||||
|
*/
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
// Mock document
|
||||||
|
const mockDocument = {
|
||||||
|
createElement: (tag) => {
|
||||||
|
const element = {
|
||||||
|
style: {},
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
placeholder: '',
|
||||||
|
title: '',
|
||||||
|
addEventListener: () => {},
|
||||||
|
appendChild: () => {},
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
focus: () => {}
|
||||||
|
};
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
appendChild: () => {}
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
addEventListener: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock console
|
||||||
|
const mockConsole = {
|
||||||
|
log: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load spatial-search.js
|
||||||
|
const spatialSearchPath = path.join(ROOT, 'js', 'spatial-search.js');
|
||||||
|
const spatialSearchCode = fs.readFileSync(spatialSearchPath, 'utf8');
|
||||||
|
|
||||||
|
// Create VM context
|
||||||
|
const context = {
|
||||||
|
module: { exports: {} },
|
||||||
|
exports: {},
|
||||||
|
console: mockConsole,
|
||||||
|
document: mockDocument,
|
||||||
|
window: {
|
||||||
|
addEventListener: () => {},
|
||||||
|
location: { protocol: 'http:', hostname: 'localhost' }
|
||||||
|
},
|
||||||
|
Math: Math,
|
||||||
|
setTimeout: () => {},
|
||||||
|
clearTimeout: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute spatial-search.js in context
|
||||||
|
const vm = require('node:vm');
|
||||||
|
vm.runInNewContext(spatialSearchCode, context);
|
||||||
|
|
||||||
|
// Get SpatialSearch class
|
||||||
|
const SpatialSearch = context.window.SpatialSearch || context.module.exports;
|
||||||
|
|
||||||
|
test('SpatialSearch loads correctly', () => {
|
||||||
|
assert.ok(SpatialSearch, 'SpatialSearch should be defined');
|
||||||
|
assert.ok(typeof SpatialSearch === 'function', 'SpatialSearch should be a constructor');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch can be instantiated', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
assert.ok(search, 'SpatialSearch instance should be created');
|
||||||
|
assert.equal(search.maxDistance, 1000, 'Should have default max distance');
|
||||||
|
assert.equal(search.searchDelay, 300, 'Should have default search delay');
|
||||||
|
assert.ok(search.entities, 'Should have entities Map');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch can register entities', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
search.registerEntity('user1', {
|
||||||
|
name: 'Alice',
|
||||||
|
type: 'user',
|
||||||
|
position: { x: 10, y: 0, z: 5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
search.registerEntity('user2', {
|
||||||
|
name: 'Bob',
|
||||||
|
type: 'user',
|
||||||
|
position: { x: 20, y: 0, z: 10 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(search.entities.size, 2, 'Should have 2 entities');
|
||||||
|
assert.ok(search.entities.get('user1'), 'Should have user1');
|
||||||
|
assert.ok(search.entities.get('user2'), 'Should have user2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch can unregister entities', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
search.registerEntity('user1', { name: 'Alice', type: 'user' });
|
||||||
|
search.registerEntity('user2', { name: 'Bob', type: 'user' });
|
||||||
|
|
||||||
|
assert.equal(search.entities.size, 2, 'Should have 2 entities');
|
||||||
|
|
||||||
|
search.unregisterEntity('user1');
|
||||||
|
assert.equal(search.entities.size, 1, 'Should have 1 entity after unregister');
|
||||||
|
assert.ok(!search.entities.get('user1'), 'Should not have user1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch can update entity position', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
search.registerEntity('user1', {
|
||||||
|
name: 'Alice',
|
||||||
|
type: 'user',
|
||||||
|
position: { x: 10, y: 0, z: 5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPos = { x: 15, y: 0, z: 10 };
|
||||||
|
search.updateEntityPosition('user1', newPos);
|
||||||
|
|
||||||
|
const entity = search.entities.get('user1');
|
||||||
|
assert.deepEqual(entity.position, newPos, 'Should update position');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch calculates distance correctly', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
// Mock getLocalPlayerPosition
|
||||||
|
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||||
|
|
||||||
|
const pos1 = { x: 3, y: 0, z: 4 };
|
||||||
|
const distance1 = search.calculateDistance(pos1);
|
||||||
|
assert.equal(distance1, 5, 'Should calculate 3-4-5 triangle correctly');
|
||||||
|
|
||||||
|
const pos2 = { x: 0, y: 0, z: 0 };
|
||||||
|
const distance2 = search.calculateDistance(pos2);
|
||||||
|
assert.equal(distance2, 0, 'Should be 0 at same position');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch calculates direction correctly', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
// Mock getLocalPlayerPosition
|
||||||
|
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||||
|
|
||||||
|
// Test different directions
|
||||||
|
assert.equal(search.calculateDirection({ x: 10, y: 0, z: 0 }), 'E', 'Should be East');
|
||||||
|
assert.equal(search.calculateDirection({ x: 0, y: 0, z: 10 }), 'S', 'Should be South');
|
||||||
|
assert.equal(search.calculateDirection({ x: -10, y: 0, z: 0 }), 'W', 'Should be West');
|
||||||
|
assert.equal(search.calculateDirection({ x: 0, y: 0, z: -10 }), 'N', 'Should be North');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch searches entities correctly', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
// Mock getLocalPlayerPosition
|
||||||
|
search.getLocalPlayerPosition = () => ({ x: 0, y: 0, z: 0 });
|
||||||
|
|
||||||
|
// Register entities
|
||||||
|
search.registerEntity('user1', {
|
||||||
|
name: 'Alice',
|
||||||
|
type: 'user',
|
||||||
|
position: { x: 10, y: 0, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
search.registerEntity('user2', {
|
||||||
|
name: 'Bob',
|
||||||
|
type: 'user',
|
||||||
|
position: { x: 20, y: 0, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
search.registerEntity('obj1', {
|
||||||
|
name: 'Apple',
|
||||||
|
type: 'object',
|
||||||
|
position: { x: 5, y: 0, z: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search for 'ali'
|
||||||
|
const results1 = search.searchEntities('ali');
|
||||||
|
assert.equal(results1.length, 1, 'Should find 1 result for "ali"');
|
||||||
|
assert.equal(results1[0].name, 'Alice', 'Should find Alice');
|
||||||
|
|
||||||
|
// Search for 'bob'
|
||||||
|
const results2 = search.searchEntities('bob');
|
||||||
|
assert.equal(results2.length, 1, 'Should find 1 result for "bob"');
|
||||||
|
assert.equal(results2[0].name, 'Bob', 'Should find Bob');
|
||||||
|
|
||||||
|
// Search for 'user' (type)
|
||||||
|
const results3 = search.searchEntities('user');
|
||||||
|
assert.equal(results3.length, 2, 'Should find 2 results for "user"');
|
||||||
|
|
||||||
|
// Search for 'apple'
|
||||||
|
const results4 = search.searchEntities('apple');
|
||||||
|
assert.equal(results4.length, 1, 'Should find 1 result for "apple"');
|
||||||
|
assert.equal(results4[0].name, 'Apple', 'Should find Apple');
|
||||||
|
|
||||||
|
// Search for non-existent
|
||||||
|
const results5 = search.searchEntities('xyz');
|
||||||
|
assert.equal(results5.length, 0, 'Should find 0 results for "xyz"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpatialSearch gets status', () => {
|
||||||
|
const search = new SpatialSearch();
|
||||||
|
|
||||||
|
search.registerEntity('user1', { name: 'Alice', type: 'user' });
|
||||||
|
search.registerEntity('user2', { name: 'Bob', type: 'user' });
|
||||||
|
|
||||||
|
const status = search.getStatus();
|
||||||
|
|
||||||
|
assert.ok(status, 'Should return status object');
|
||||||
|
assert.equal(status.entityCount, 2, 'Should have 2 entities');
|
||||||
|
assert.equal(status.maxDistance, 1000, 'Should have correct max distance');
|
||||||
|
assert.equal(status.searchDelay, 300, 'Should have correct search delay');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('All SpatialSearch tests passed!');
|
||||||
Reference in New Issue
Block a user