Compare commits

...

2 Commits

Author SHA1 Message Date
6dcdc0544a feat(client): Heartbeat ping/pong + auto-reconnect with position restore (#1535)
Some checks failed
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m11s
Review Approval Gate / verify-review (pull_request) Successful in 10s
2026-04-15 03:47:42 +00:00
4c7abbc0b3 feat(server): Handle heartbeat ping with pong + user count (#1535) 2026-04-15 03:47:41 +00:00
2 changed files with 69 additions and 0 deletions

59
app.js
View File

@@ -69,6 +69,39 @@ function escHtml(s) {
let hermesWs = null;
let wsReconnectTimer = null;
let wsConnected = false;
let _heartbeatTimer = null;
let _heartbeatMissed = 0;
let _savedPlayerPos = null;
let _savedPlayerRot = null;
const HEARTBEAT_INTERVAL = 30000; // 30s
const HEARTBEAT_MAX_MISSED = 2;
function _startHeartbeat() {
_stopHeartbeat();
_heartbeatMissed = 0;
_heartbeatTimer = setInterval(() => {
if (!hermesWs || hermesWs.readyState !== WebSocket.OPEN) return;
if (_heartbeatMissed >= HEARTBEAT_MAX_MISSED) {
console.warn('Heartbeat: ' + _heartbeatMissed + ' missed pongs. Reconnecting...');
hermesWs.close();
return;
}
_heartbeatMissed++;
try {
hermesWs.send(JSON.stringify({ type: 'ping' }));
} catch (e) {
console.warn('Heartbeat send failed:', e);
}
}, HEARTBEAT_INTERVAL);
}
function _stopHeartbeat() {
if (_heartbeatTimer) {
clearInterval(_heartbeatTimer);
_heartbeatTimer = null;
}
_heartbeatMissed = 0;
}
// ═══ EVENNIA ROOM STATE ═══
let evenniaRoom = null; // {title, desc, exits[], objects[], occupants[], timestamp, roomKey}
let evenniaConnected = false;
@@ -2213,6 +2246,18 @@ function connectHermes() {
} catch (e) {
console.warn('[Mnemosyne] Failed to send sync_request:', e);
}
// Heartbeat: start ping timer
_startHeartbeat();
// Restore position after reconnect
if (_savedPlayerPos && typeof playerPos !== 'undefined') {
playerPos = _savedPlayerPos;
if (_savedPlayerRot && typeof playerRot !== 'undefined') playerRot = _savedPlayerRot;
console.log('Restored player position after reconnect.');
_savedPlayerPos = null;
_savedPlayerRot = null;
}
};
// Initialize MemPalace
@@ -2221,6 +2266,16 @@ function connectHermes() {
hermesWs.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
// Heartbeat pong: reset missed count
if (data.type === 'pong') {
_heartbeatMissed = 0;
if (data.user_count !== undefined) {
console.debug('Heartbeat pong: ' + data.user_count + ' users connected.');
}
return;
}
handleHermesMessage(data);
// Store in MemPalace
@@ -2238,6 +2293,10 @@ function connectHermes() {
hermesWs.onclose = () => {
console.warn('Hermes disconnected. Retrying in 5s...');
wsConnected = false;
_stopHeartbeat();
// Save position for reconnect
if (typeof playerPos !== 'undefined') _savedPlayerPos = playerPos;
if (typeof playerRot !== 'undefined') _savedPlayerRot = playerRot;
hermesWs = null;
updateWsHudStatus(false);
refreshWorkshopPanel();

View File

@@ -44,6 +44,16 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
# Optional: log specific important message types
if msg_type in ["agent_register", "thought", "action"]:
logger.debug(f"Received {msg_type} from {addr}")
# Heartbeat: respond with pong + user count
if msg_type == "ping":
pong = json.dumps({
"type": "pong",
"user_count": len(clients),
"timestamp": asyncio.get_event_loop().time()
})
await websocket.send(pong)
continue
except (json.JSONDecodeError, TypeError):
pass