Spaces:
Sleeping
Sleeping
Deploy Aether Garden application
Browse files- .pytest_cache/v/cache/lastfailed +3 -0
- app.py +102 -16
- assets/diorama.html +279 -16
- ui/hero.py +11 -1
- ui/map.py +9 -7
- ui/styles.css +435 -4
- world/presence.py +68 -6
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tests/test_diorama_scene.py": true
|
| 3 |
+
}
|
app.py
CHANGED
|
@@ -14,7 +14,13 @@ load_dotenv()
|
|
| 14 |
|
| 15 |
from world.database import get_world_state, init_database
|
| 16 |
from world.quests import assign_quest, get_entity_quest, get_active_quests
|
| 17 |
-
from world.presence import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from world.entities import (
|
| 19 |
check_rate_limit,
|
| 20 |
get_all_entities,
|
|
@@ -337,6 +343,37 @@ def get_live_presence(session_id: str) -> str:
|
|
| 337 |
return ""
|
| 338 |
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
def trigger_live_tick() -> str:
|
| 341 |
"""Run one simulation tick on demand and return a summary."""
|
| 342 |
try:
|
|
@@ -511,6 +548,7 @@ def build_app() -> gr.Blocks:
|
|
| 511 |
'<h2 class="section-title">Walk the Eight Sacred Places</h2>'
|
| 512 |
'<p class="section-desc">Pick a place below — then <strong>WASD to walk</strong>, '
|
| 513 |
'<strong>drag to look</strong>, <strong>E to talk</strong> to any soul you find. '
|
|
|
|
| 514 |
'Or press ✦ to wander somewhere at random.</p>'
|
| 515 |
'</div>'
|
| 516 |
)
|
|
@@ -748,6 +786,26 @@ def build_app() -> gr.Blocks:
|
|
| 748 |
api_name="assign_quest",
|
| 749 |
)
|
| 750 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
# ── Presence heartbeat (every 10s) ──
|
| 752 |
presence_timer = gr.Timer(10)
|
| 753 |
presence_timer.tick(
|
|
@@ -796,29 +854,28 @@ def build_app() -> gr.Blocks:
|
|
| 796 |
new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
|
| 797 |
|
| 798 |
window._aetherHoverDiorama = false;
|
|
|
|
| 799 |
window._aetherActiveFrame = null;
|
| 800 |
const GAME_KEYS = new Set([
|
| 801 |
'KeyW','KeyA','KeyS','KeyD',
|
| 802 |
'ArrowUp','ArrowDown','ArrowLeft','ArrowRight',
|
| 803 |
-
'KeyE','ShiftLeft','ShiftRight','Escape'
|
| 804 |
]);
|
| 805 |
|
| 806 |
const sendKeyToFrame = (frame, code, action) => {
|
| 807 |
if (!frame) return;
|
|
|
|
|
|
|
|
|
|
| 808 |
const evtType = action === 'down' ? 'keydown' : 'keyup';
|
| 809 |
try {
|
| 810 |
const doc = frame.contentDocument;
|
| 811 |
if (doc) {
|
| 812 |
doc.dispatchEvent(new KeyboardEvent(evtType, {
|
| 813 |
-
code,
|
| 814 |
-
bubbles: true, cancelable: true,
|
| 815 |
}));
|
| 816 |
-
return;
|
| 817 |
}
|
| 818 |
} catch (_) {}
|
| 819 |
-
try {
|
| 820 |
-
frame.contentWindow?.postMessage({ type: 'aether_key', code, action }, '*');
|
| 821 |
-
} catch (_) {}
|
| 822 |
};
|
| 823 |
|
| 824 |
const wireDioramaFrames = () => {
|
|
@@ -840,18 +897,22 @@ def build_app() -> gr.Blocks:
|
|
| 840 |
window._aetherActiveFrame = frame || window._aetherActiveFrame;
|
| 841 |
});
|
| 842 |
wrap.addEventListener('mouseleave', () => {
|
| 843 |
-
window._aetherHoverDiorama = false;
|
| 844 |
});
|
| 845 |
wrap.addEventListener('mousedown', activate);
|
|
|
|
| 846 |
});
|
| 847 |
|
| 848 |
document.querySelectorAll('iframe.diorama-frame').forEach((frame) => {
|
| 849 |
if (frame.dataset.dioramaBound) return;
|
| 850 |
frame.dataset.dioramaBound = '1';
|
| 851 |
frame.setAttribute('tabindex', '0');
|
|
|
|
| 852 |
frame.addEventListener('load', () => {
|
|
|
|
| 853 |
setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 120);
|
| 854 |
setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 600);
|
|
|
|
| 855 |
});
|
| 856 |
});
|
| 857 |
};
|
|
@@ -868,17 +929,15 @@ def build_app() -> gr.Blocks:
|
|
| 868 |
if (el.isContentEditable) return true;
|
| 869 |
return !!el.closest?.('textarea, input, [contenteditable="true"]');
|
| 870 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
const forwardKey = (e, action) => {
|
| 872 |
if (!GAME_KEYS.has(e.code)) return;
|
| 873 |
if (typingInForm()) return;
|
| 874 |
-
const frame =
|
| 875 |
-
|| document.querySelector('.diorama-wrap:hover iframe.diorama-frame')
|
| 876 |
-
|| document.querySelector('iframe.diorama-frame');
|
| 877 |
if (!frame) return;
|
| 878 |
-
if (!window._aetherHoverDiorama && action === 'down') {
|
| 879 |
-
const wrap = frame.closest('.diorama-wrap');
|
| 880 |
-
if (!wrap?.matches(':hover')) return;
|
| 881 |
-
}
|
| 882 |
e.preventDefault();
|
| 883 |
e.stopImmediatePropagation();
|
| 884 |
sendKeyToFrame(frame, e.code, action);
|
|
@@ -887,10 +946,37 @@ def build_app() -> gr.Blocks:
|
|
| 887 |
window.addEventListener('keyup', (e) => forwardKey(e, 'up'), true);
|
| 888 |
}
|
| 889 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
// Portal navigation from diorama iframe
|
| 891 |
if (!window._aetherPortalListenerBound) {
|
| 892 |
window._aetherPortalListenerBound = true;
|
| 893 |
window.addEventListener('message', (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
if (!e.data || e.data.type !== 'aether_portal') return;
|
| 895 |
const id = String(e.data.locationId || '');
|
| 896 |
if (!id) return;
|
|
|
|
| 14 |
|
| 15 |
from world.database import get_world_state, init_database
|
| 16 |
from world.quests import assign_quest, get_entity_quest, get_active_quests
|
| 17 |
+
from world.presence import (
|
| 18 |
+
cleanup_stale_sessions,
|
| 19 |
+
heartbeat as presence_heartbeat,
|
| 20 |
+
get_active_count,
|
| 21 |
+
get_active_visitors,
|
| 22 |
+
get_recent_arrivals,
|
| 23 |
+
)
|
| 24 |
from world.entities import (
|
| 25 |
check_rate_limit,
|
| 26 |
get_all_entities,
|
|
|
|
| 343 |
return ""
|
| 344 |
|
| 345 |
|
| 346 |
+
def diorama_presence_api(location_id, visitor_id: str, x, z, yaw) -> dict:
|
| 347 |
+
"""Store a visitor's in-scene position and return nearby visitors."""
|
| 348 |
+
try:
|
| 349 |
+
loc_id = int(float(location_id))
|
| 350 |
+
visitor = (visitor_id or "").strip()[:80]
|
| 351 |
+
if not visitor:
|
| 352 |
+
return {"visitors": []}
|
| 353 |
+
px = max(-24.0, min(24.0, float(x or 0)))
|
| 354 |
+
pz = max(-24.0, min(24.0, float(z or 0)))
|
| 355 |
+
pyaw = float(yaw or 0)
|
| 356 |
+
short = visitor.replace("aether-", "").replace("_", "-")[-4:].upper()
|
| 357 |
+
presence_heartbeat(
|
| 358 |
+
visitor,
|
| 359 |
+
loc_id,
|
| 360 |
+
x=px,
|
| 361 |
+
z=pz,
|
| 362 |
+
yaw=pyaw,
|
| 363 |
+
display_name=f"Visitor {short}",
|
| 364 |
+
)
|
| 365 |
+
cleanup_stale_sessions(max_age_seconds=300)
|
| 366 |
+
return {
|
| 367 |
+
"visitors": get_active_visitors(
|
| 368 |
+
loc_id,
|
| 369 |
+
exclude_session_id=visitor,
|
| 370 |
+
window_seconds=22,
|
| 371 |
+
)
|
| 372 |
+
}
|
| 373 |
+
except Exception:
|
| 374 |
+
return {"visitors": []}
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def trigger_live_tick() -> str:
|
| 378 |
"""Run one simulation tick on demand and return a summary."""
|
| 379 |
try:
|
|
|
|
| 548 |
'<h2 class="section-title">Walk the Eight Sacred Places</h2>'
|
| 549 |
'<p class="section-desc">Pick a place below — then <strong>WASD to walk</strong>, '
|
| 550 |
'<strong>drag to look</strong>, <strong>E to talk</strong> to any soul you find. '
|
| 551 |
+
'Other live visitors appear as golden wisps when they step into the same place. '
|
| 552 |
'Or press ✦ to wander somewhere at random.</p>'
|
| 553 |
'</div>'
|
| 554 |
)
|
|
|
|
| 786 |
api_name="assign_quest",
|
| 787 |
)
|
| 788 |
|
| 789 |
+
# ── Diorama multiplayer presence API ──
|
| 790 |
+
presence_loc_comp = gr.Textbox(visible=False, elem_id="presence_location_input")
|
| 791 |
+
presence_visitor_comp = gr.Textbox(visible=False, elem_id="presence_visitor_input")
|
| 792 |
+
presence_x_comp = gr.Textbox(visible=False, elem_id="presence_x_input")
|
| 793 |
+
presence_z_comp = gr.Textbox(visible=False, elem_id="presence_z_input")
|
| 794 |
+
presence_yaw_comp = gr.Textbox(visible=False, elem_id="presence_yaw_input")
|
| 795 |
+
presence_json_comp = gr.JSON(visible=False, elem_id="presence_json_output")
|
| 796 |
+
presence_yaw_comp.change(
|
| 797 |
+
fn=diorama_presence_api,
|
| 798 |
+
inputs=[
|
| 799 |
+
presence_loc_comp,
|
| 800 |
+
presence_visitor_comp,
|
| 801 |
+
presence_x_comp,
|
| 802 |
+
presence_z_comp,
|
| 803 |
+
presence_yaw_comp,
|
| 804 |
+
],
|
| 805 |
+
outputs=[presence_json_comp],
|
| 806 |
+
api_name="diorama_presence",
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
# ── Presence heartbeat (every 10s) ──
|
| 810 |
presence_timer = gr.Timer(10)
|
| 811 |
presence_timer.tick(
|
|
|
|
| 854 |
new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
|
| 855 |
|
| 856 |
window._aetherHoverDiorama = false;
|
| 857 |
+
window._aetherDioramaActive = false;
|
| 858 |
window._aetherActiveFrame = null;
|
| 859 |
const GAME_KEYS = new Set([
|
| 860 |
'KeyW','KeyA','KeyS','KeyD',
|
| 861 |
'ArrowUp','ArrowDown','ArrowLeft','ArrowRight',
|
| 862 |
+
'KeyE','KeyQ','ShiftLeft','ShiftRight','Escape'
|
| 863 |
]);
|
| 864 |
|
| 865 |
const sendKeyToFrame = (frame, code, action) => {
|
| 866 |
if (!frame) return;
|
| 867 |
+
try {
|
| 868 |
+
frame.contentWindow?.postMessage({ type: 'aether_key', code, action }, '*');
|
| 869 |
+
} catch (_) {}
|
| 870 |
const evtType = action === 'down' ? 'keydown' : 'keyup';
|
| 871 |
try {
|
| 872 |
const doc = frame.contentDocument;
|
| 873 |
if (doc) {
|
| 874 |
doc.dispatchEvent(new KeyboardEvent(evtType, {
|
| 875 |
+
code, bubbles: true, cancelable: true,
|
|
|
|
| 876 |
}));
|
|
|
|
| 877 |
}
|
| 878 |
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
| 879 |
};
|
| 880 |
|
| 881 |
const wireDioramaFrames = () => {
|
|
|
|
| 897 |
window._aetherActiveFrame = frame || window._aetherActiveFrame;
|
| 898 |
});
|
| 899 |
wrap.addEventListener('mouseleave', () => {
|
| 900 |
+
if (!window._aetherDioramaActive) window._aetherHoverDiorama = false;
|
| 901 |
});
|
| 902 |
wrap.addEventListener('mousedown', activate);
|
| 903 |
+
wrap.addEventListener('click', activate);
|
| 904 |
});
|
| 905 |
|
| 906 |
document.querySelectorAll('iframe.diorama-frame').forEach((frame) => {
|
| 907 |
if (frame.dataset.dioramaBound) return;
|
| 908 |
frame.dataset.dioramaBound = '1';
|
| 909 |
frame.setAttribute('tabindex', '0');
|
| 910 |
+
window._aetherActiveFrame = frame;
|
| 911 |
frame.addEventListener('load', () => {
|
| 912 |
+
window._aetherActiveFrame = frame;
|
| 913 |
setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 120);
|
| 914 |
setTimeout(() => { try { frame.contentWindow?.postMessage('diorama-resize', '*'); } catch (_) {} }, 600);
|
| 915 |
+
setTimeout(() => { try { frame.contentWindow?.postMessage({ type: 'aether_activate' }, '*'); } catch (_) {} }, 200);
|
| 916 |
});
|
| 917 |
});
|
| 918 |
};
|
|
|
|
| 929 |
if (el.isContentEditable) return true;
|
| 930 |
return !!el.closest?.('textarea, input, [contenteditable="true"]');
|
| 931 |
};
|
| 932 |
+
const getActiveFrame = () =>
|
| 933 |
+
window._aetherActiveFrame
|
| 934 |
+
|| document.querySelector('.diorama-wrap iframe.diorama-frame')
|
| 935 |
+
|| document.querySelector('iframe.diorama-frame');
|
| 936 |
const forwardKey = (e, action) => {
|
| 937 |
if (!GAME_KEYS.has(e.code)) return;
|
| 938 |
if (typingInForm()) return;
|
| 939 |
+
const frame = getActiveFrame();
|
|
|
|
|
|
|
| 940 |
if (!frame) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 941 |
e.preventDefault();
|
| 942 |
e.stopImmediatePropagation();
|
| 943 |
sendKeyToFrame(frame, e.code, action);
|
|
|
|
| 946 |
window.addEventListener('keyup', (e) => forwardKey(e, 'up'), true);
|
| 947 |
}
|
| 948 |
|
| 949 |
+
if (!window._aetherFocusRecheckBound) {
|
| 950 |
+
window._aetherFocusRecheckBound = true;
|
| 951 |
+
setInterval(() => {
|
| 952 |
+
if (!window._aetherDioramaActive) return;
|
| 953 |
+
const el = document.activeElement;
|
| 954 |
+
const typing = el && (
|
| 955 |
+
el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'
|
| 956 |
+
|| el.tagName === 'SELECT' || el.isContentEditable
|
| 957 |
+
);
|
| 958 |
+
if (typing) return;
|
| 959 |
+
const frame = window._aetherActiveFrame
|
| 960 |
+
|| document.querySelector('iframe.diorama-frame');
|
| 961 |
+
if (!frame) return;
|
| 962 |
+
try { frame.contentWindow?.postMessage({ type: 'aether_activate' }, '*'); } catch (_) {}
|
| 963 |
+
}, 2500);
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
// Portal navigation from diorama iframe
|
| 967 |
if (!window._aetherPortalListenerBound) {
|
| 968 |
window._aetherPortalListenerBound = true;
|
| 969 |
window.addEventListener('message', (e) => {
|
| 970 |
+
if (e.data && e.data.type === 'aether_diorama_active') {
|
| 971 |
+
const frames = Array.from(document.querySelectorAll('iframe.diorama-frame'));
|
| 972 |
+
const active = frames.find((frame) => frame.contentWindow === e.source);
|
| 973 |
+
if (active) {
|
| 974 |
+
window._aetherActiveFrame = active;
|
| 975 |
+
window._aetherHoverDiorama = true;
|
| 976 |
+
window._aetherDioramaActive = true;
|
| 977 |
+
}
|
| 978 |
+
return;
|
| 979 |
+
}
|
| 980 |
if (!e.data || e.data.type !== 'aether_portal') return;
|
| 981 |
const id = String(e.data.locationId || '');
|
| 982 |
if (!id) return;
|
assets/diorama.html
CHANGED
|
@@ -10,13 +10,39 @@
|
|
| 10 |
#canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: crosshair; outline: none; }
|
| 11 |
|
| 12 |
#focus-gate {
|
| 13 |
-
position: fixed; left: 50%;
|
| 14 |
-
z-index: 25; display: flex; flex-direction: column; align-items: center; gap: 0.
|
| 15 |
-
padding:
|
| 16 |
-
background: rgba(8,12,10,0.
|
| 17 |
color: #f0d9a0; outline: none; pointer-events: auto;
|
| 18 |
-
box-shadow: 0
|
| 19 |
animation: gatePulse 2.4s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
#focus-gate.hidden { display: none; }
|
| 22 |
#focus-gate p {
|
|
@@ -25,8 +51,8 @@
|
|
| 25 |
}
|
| 26 |
#focus-gate span { font-size: 0.72rem; color: #b8b090; font-style: italic; }
|
| 27 |
@keyframes gatePulse {
|
| 28 |
-
0%,100% { opacity: 0.88; transform:
|
| 29 |
-
50% { opacity: 1; transform:
|
| 30 |
}
|
| 31 |
|
| 32 |
#loading {
|
|
@@ -113,6 +139,17 @@
|
|
| 113 |
}
|
| 114 |
#ctrl-tip.show { opacity: 1; }
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
/* ── Top-right buttons ── */
|
| 118 |
#top-right {
|
|
@@ -233,9 +270,10 @@
|
|
| 233 |
</div>
|
| 234 |
|
| 235 |
<div id="focus-gate" tabindex="0">
|
| 236 |
-
<p>Click to
|
| 237 |
-
<span>
|
| 238 |
</div>
|
|
|
|
| 239 |
<div id="move-pad" aria-label="Movement controls">
|
| 240 |
<span class="mv-spacer"></span>
|
| 241 |
<button type="button" class="mv-btn" data-key="KeyW">W</button>
|
|
@@ -253,6 +291,7 @@
|
|
| 253 |
<div class="hud-name" id="hud-name"></div>
|
| 254 |
<div class="hud-ambience" id="hud-ambience"></div>
|
| 255 |
</div>
|
|
|
|
| 256 |
<div id="ctrl-tip">WASD · Move | Drag · Look | E · Talk | Shift · Sprint</div>
|
| 257 |
<div id="top-right">
|
| 258 |
<button class="btn-hud" id="audio-btn" title="Toggle ambience">🔇</button>
|
|
@@ -372,8 +411,16 @@
|
|
| 372 |
const SENS = 0.0028;
|
| 373 |
|
| 374 |
const focusGate = document.getElementById('focus-gate');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
focusGate.addEventListener('click', activateControls);
|
| 376 |
focusGate.addEventListener('keydown', e => { if (e.code === 'Enter' || e.code === 'Space') activateControls(); });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
canvas.addEventListener('click', () => { activateControls(); });
|
| 378 |
canvas.addEventListener('mousedown', e => {
|
| 379 |
if (e.button !== 0) return;
|
|
@@ -458,10 +505,13 @@
|
|
| 458 |
if (gate) gate.classList.add('hidden');
|
| 459 |
canvas.focus({ preventScroll: true });
|
| 460 |
try { window.focus(); } catch (_) {}
|
|
|
|
| 461 |
try { window.parent?.postMessage({ type: 'aether_diorama_active' }, '*'); } catch (_) {}
|
|
|
|
| 462 |
}
|
| 463 |
|
| 464 |
function activateControls() {
|
|
|
|
| 465 |
grabFocus();
|
| 466 |
enterFPS();
|
| 467 |
}
|
|
@@ -471,13 +521,22 @@
|
|
| 471 |
// ═══════════════════════════════════════════
|
| 472 |
const keys = new Set();
|
| 473 |
const MOVE_KEYS = new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight']);
|
| 474 |
-
const GAME_KEYS = new Set([...MOVE_KEYS, 'KeyE', 'ShiftLeft', 'ShiftRight', 'Escape']);
|
| 475 |
|
| 476 |
function pressKey(code) {
|
|
|
|
| 477 |
keys.add(code);
|
| 478 |
autoOrbit = false;
|
| 479 |
-
if (MOVE_KEYS.has(code))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
if (code === 'KeyE') tryInteract();
|
|
|
|
| 481 |
}
|
| 482 |
function releaseKey(code) { keys.delete(code); }
|
| 483 |
|
|
@@ -985,6 +1044,188 @@
|
|
| 985 |
}
|
| 986 |
buildPortals();
|
| 987 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
// ═══════════════════════════════════════════
|
| 989 |
// AUDIO — per-location pad synthesizer
|
| 990 |
// ═══════════════════════════════════════════
|
|
@@ -1204,7 +1445,7 @@
|
|
| 1204 |
// ── Soul chat via Gradio API ──
|
| 1205 |
async function askSoul(soulId, message) {
|
| 1206 |
try {
|
| 1207 |
-
const res = await fetch('
|
| 1208 |
method: 'POST',
|
| 1209 |
headers: {'Content-Type':'application/json'},
|
| 1210 |
body: JSON.stringify({ data: [soulId, message] })
|
|
@@ -1308,7 +1549,7 @@
|
|
| 1308 |
chatHistory.appendChild(vMsg);
|
| 1309 |
// Call quest API
|
| 1310 |
try {
|
| 1311 |
-
const res=await fetch('
|
| 1312 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1313 |
body:JSON.stringify({data:[soulId, questTitle]})
|
| 1314 |
});
|
|
@@ -1597,6 +1838,18 @@
|
|
| 1597 |
else if(ea.type==='portalLight'){ea.light.intensity=0.7+Math.sin(t*2.2+ea.phase)*0.3;}
|
| 1598 |
}
|
| 1599 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1600 |
// Particles
|
| 1601 |
const mp=motes.geometry.attributes.position;
|
| 1602 |
for(let i=0;i<moteN;i++){
|
|
@@ -1622,10 +1875,20 @@
|
|
| 1622 |
window.addEventListener('message', e => {
|
| 1623 |
if (e.data === 'diorama-resize') resizeRenderer();
|
| 1624 |
if (e.data?.type === 'aether_key') {
|
| 1625 |
-
|
| 1626 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1627 |
}
|
| 1628 |
-
if (e.data?.type === 'aether_activate') activateControls();
|
| 1629 |
});
|
| 1630 |
</script>
|
| 1631 |
</body>
|
|
|
|
| 10 |
#canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: crosshair; outline: none; }
|
| 11 |
|
| 12 |
#focus-gate {
|
| 13 |
+
position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
| 14 |
+
z-index: 25; display: flex; flex-direction: column; align-items: center; gap: 0.45rem;
|
| 15 |
+
padding: 1.1rem 1.6rem; border-radius: 16px; cursor: pointer; text-align: center;
|
| 16 |
+
background: rgba(8,12,10,0.88); border: 2px solid rgba(212,168,75,0.55);
|
| 17 |
color: #f0d9a0; outline: none; pointer-events: auto;
|
| 18 |
+
box-shadow: 0 12px 48px rgba(0,0,0,0.6), 0 0 40px rgba(212,168,75,0.15);
|
| 19 |
animation: gatePulse 2.4s ease-in-out infinite;
|
| 20 |
+
backdrop-filter: blur(8px);
|
| 21 |
+
}
|
| 22 |
+
#focus-gate::before {
|
| 23 |
+
content: '⬡'; font-size: 1.4rem; color: #d4a84b; opacity: 0.7;
|
| 24 |
+
animation: gateSpin 6s linear infinite;
|
| 25 |
+
}
|
| 26 |
+
@keyframes gateSpin { to { transform: rotate(360deg); } }
|
| 27 |
+
#walk-hint {
|
| 28 |
+
position: fixed; left: 50%; bottom: 14%; transform: translateX(-50%);
|
| 29 |
+
z-index: 24; pointer-events: none; opacity: 0; transition: opacity 0.4s;
|
| 30 |
+
padding: 0.5rem 1.2rem; border-radius: 999px;
|
| 31 |
+
background: rgba(8,12,10,0.78); border: 1px solid rgba(212,168,75,0.35);
|
| 32 |
+
color: #d4a84b; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase;
|
| 33 |
+
backdrop-filter: blur(6px);
|
| 34 |
+
animation: walkHintBob 2s ease-in-out infinite;
|
| 35 |
+
}
|
| 36 |
+
#walk-hint.show { opacity: 1; }
|
| 37 |
+
@keyframes walkHintBob {
|
| 38 |
+
0%,100% { transform: translateX(-50%) translateY(0); }
|
| 39 |
+
50% { transform: translateX(-50%) translateY(-4px); }
|
| 40 |
+
}
|
| 41 |
+
@keyframes waveFade {
|
| 42 |
+
0% { opacity: 0; transform: translateX(-50%) translateY(8px) scale(0.9); }
|
| 43 |
+
15% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
| 44 |
+
80% { opacity: 1; }
|
| 45 |
+
100% { opacity: 0; transform: translateX(-50%) translateY(-12px) scale(0.95); }
|
| 46 |
}
|
| 47 |
#focus-gate.hidden { display: none; }
|
| 48 |
#focus-gate p {
|
|
|
|
| 51 |
}
|
| 52 |
#focus-gate span { font-size: 0.72rem; color: #b8b090; font-style: italic; }
|
| 53 |
@keyframes gatePulse {
|
| 54 |
+
0%,100% { opacity: 0.88; transform: translate(-50%, -50%) scale(1); }
|
| 55 |
+
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
|
| 56 |
}
|
| 57 |
|
| 58 |
#loading {
|
|
|
|
| 139 |
}
|
| 140 |
#ctrl-tip.show { opacity: 1; }
|
| 141 |
|
| 142 |
+
#multiplayer-hud {
|
| 143 |
+
position: fixed; left: 0.85rem; top: 0.75rem; z-index: 8;
|
| 144 |
+
pointer-events: none; max-width: min(300px, 70vw);
|
| 145 |
+
color: #d7c99d; font-size: 0.68rem; letter-spacing: 0.08em;
|
| 146 |
+
text-transform: uppercase; opacity: 0; transition: opacity 0.35s;
|
| 147 |
+
background: rgba(8,12,10,0.62); border: 1px solid rgba(212,168,75,0.22);
|
| 148 |
+
border-radius: 999px; padding: 0.34rem 0.75rem; backdrop-filter: blur(5px);
|
| 149 |
+
}
|
| 150 |
+
#multiplayer-hud.show { opacity: 1; }
|
| 151 |
+
#multiplayer-hud b { color: #f0d9a0; font-weight: 600; }
|
| 152 |
+
|
| 153 |
|
| 154 |
/* ── Top-right buttons ── */
|
| 155 |
#top-right {
|
|
|
|
| 270 |
</div>
|
| 271 |
|
| 272 |
<div id="focus-gate" tabindex="0">
|
| 273 |
+
<p>Click to Enter</p>
|
| 274 |
+
<span>WASD to walk · drag to look · E to talk · Q to wave</span>
|
| 275 |
</div>
|
| 276 |
+
<div id="walk-hint">Press WASD to begin walking</div>
|
| 277 |
<div id="move-pad" aria-label="Movement controls">
|
| 278 |
<span class="mv-spacer"></span>
|
| 279 |
<button type="button" class="mv-btn" data-key="KeyW">W</button>
|
|
|
|
| 291 |
<div class="hud-name" id="hud-name"></div>
|
| 292 |
<div class="hud-ambience" id="hud-ambience"></div>
|
| 293 |
</div>
|
| 294 |
+
<div id="multiplayer-hud"></div>
|
| 295 |
<div id="ctrl-tip">WASD · Move | Drag · Look | E · Talk | Shift · Sprint</div>
|
| 296 |
<div id="top-right">
|
| 297 |
<button class="btn-hud" id="audio-btn" title="Toggle ambience">🔇</button>
|
|
|
|
| 411 |
const SENS = 0.0028;
|
| 412 |
|
| 413 |
const focusGate = document.getElementById('focus-gate');
|
| 414 |
+
const walkHint = document.getElementById('walk-hint');
|
| 415 |
+
let controlsActivated = false;
|
| 416 |
+
let hasMoved = false;
|
| 417 |
+
|
| 418 |
focusGate.addEventListener('click', activateControls);
|
| 419 |
focusGate.addEventListener('keydown', e => { if (e.code === 'Enter' || e.code === 'Space') activateControls(); });
|
| 420 |
+
['pointerdown', 'touchstart'].forEach(evt => {
|
| 421 |
+
focusGate.addEventListener(evt, activateControls, { passive: true });
|
| 422 |
+
canvas.addEventListener(evt, () => { if (!controlsActivated) activateControls(); }, { passive: true });
|
| 423 |
+
});
|
| 424 |
canvas.addEventListener('click', () => { activateControls(); });
|
| 425 |
canvas.addEventListener('mousedown', e => {
|
| 426 |
if (e.button !== 0) return;
|
|
|
|
| 505 |
if (gate) gate.classList.add('hidden');
|
| 506 |
canvas.focus({ preventScroll: true });
|
| 507 |
try { window.focus(); } catch (_) {}
|
| 508 |
+
try { document.body.focus(); } catch (_) {}
|
| 509 |
try { window.parent?.postMessage({ type: 'aether_diorama_active' }, '*'); } catch (_) {}
|
| 510 |
+
if (controlsActivated && !hasMoved && walkHint) walkHint.classList.add('show');
|
| 511 |
}
|
| 512 |
|
| 513 |
function activateControls() {
|
| 514 |
+
controlsActivated = true;
|
| 515 |
grabFocus();
|
| 516 |
enterFPS();
|
| 517 |
}
|
|
|
|
| 521 |
// ═══════════════════════════════════════════
|
| 522 |
const keys = new Set();
|
| 523 |
const MOVE_KEYS = new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight']);
|
| 524 |
+
const GAME_KEYS = new Set([...MOVE_KEYS, 'KeyE', 'KeyQ', 'ShiftLeft', 'ShiftRight', 'Escape']);
|
| 525 |
|
| 526 |
function pressKey(code) {
|
| 527 |
+
if (GAME_KEYS.has(code)) grabFocus();
|
| 528 |
keys.add(code);
|
| 529 |
autoOrbit = false;
|
| 530 |
+
if (MOVE_KEYS.has(code)) {
|
| 531 |
+
enterFPS();
|
| 532 |
+
if (!hasMoved) {
|
| 533 |
+
hasMoved = true;
|
| 534 |
+
if (walkHint) walkHint.classList.remove('show');
|
| 535 |
+
if (focusGate) focusGate.classList.add('hidden');
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
if (code === 'KeyE') tryInteract();
|
| 539 |
+
if (code === 'KeyQ') tryWave();
|
| 540 |
}
|
| 541 |
function releaseKey(code) { keys.delete(code); }
|
| 542 |
|
|
|
|
| 1044 |
}
|
| 1045 |
buildPortals();
|
| 1046 |
|
| 1047 |
+
// ═══════════════════════════════════════════
|
| 1048 |
+
// MULTIPLAYER PRESENCE — Gradio-compatible polling
|
| 1049 |
+
// ═══════════════════════════════════════════
|
| 1050 |
+
const multiplayerHud = document.getElementById('multiplayer-hud');
|
| 1051 |
+
const visitorId = (() => {
|
| 1052 |
+
try {
|
| 1053 |
+
let id = localStorage.getItem('aether_visitor_id');
|
| 1054 |
+
if (!id) {
|
| 1055 |
+
id = 'aether-' + (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(16).slice(2));
|
| 1056 |
+
localStorage.setItem('aether_visitor_id', id);
|
| 1057 |
+
}
|
| 1058 |
+
return id;
|
| 1059 |
+
} catch (_) {
|
| 1060 |
+
return 'aether-' + String(Date.now()) + Math.random().toString(16).slice(2);
|
| 1061 |
+
}
|
| 1062 |
+
})();
|
| 1063 |
+
const remoteVisitors = new Map();
|
| 1064 |
+
const remoteMat = new THREE.MeshBasicMaterial({
|
| 1065 |
+
color: glow,
|
| 1066 |
+
transparent: true,
|
| 1067 |
+
opacity: 0.68,
|
| 1068 |
+
blending: THREE.AdditiveBlending,
|
| 1069 |
+
depthWrite: false,
|
| 1070 |
+
});
|
| 1071 |
+
|
| 1072 |
+
function makeNameTexture(name) {
|
| 1073 |
+
const c = document.createElement('canvas');
|
| 1074 |
+
c.width = 256; c.height = 64;
|
| 1075 |
+
const ctx = c.getContext('2d');
|
| 1076 |
+
ctx.clearRect(0, 0, c.width, c.height);
|
| 1077 |
+
ctx.fillStyle = 'rgba(8,12,10,0.72)';
|
| 1078 |
+
roundRect(ctx, 18, 12, 220, 34, 17);
|
| 1079 |
+
ctx.fill();
|
| 1080 |
+
ctx.strokeStyle = 'rgba(212,168,75,0.45)';
|
| 1081 |
+
ctx.stroke();
|
| 1082 |
+
ctx.font = '600 18px Georgia, serif';
|
| 1083 |
+
ctx.fillStyle = '#f0d9a0';
|
| 1084 |
+
ctx.textAlign = 'center';
|
| 1085 |
+
ctx.textBaseline = 'middle';
|
| 1086 |
+
ctx.fillText(name || 'Visitor', 128, 29, 190);
|
| 1087 |
+
const tex = new THREE.CanvasTexture(c);
|
| 1088 |
+
tex.colorSpace = THREE.SRGBColorSpace;
|
| 1089 |
+
return tex;
|
| 1090 |
+
}
|
| 1091 |
+
function roundRect(ctx, x, y, w, h, r) {
|
| 1092 |
+
ctx.beginPath();
|
| 1093 |
+
ctx.moveTo(x + r, y);
|
| 1094 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 1095 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 1096 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 1097 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 1098 |
+
ctx.closePath();
|
| 1099 |
+
}
|
| 1100 |
+
function createRemoteVisitor(v) {
|
| 1101 |
+
const group = new THREE.Group();
|
| 1102 |
+
const body = new THREE.Mesh(
|
| 1103 |
+
new THREE.SphereGeometry(0.42, 20, 12),
|
| 1104 |
+
remoteMat.clone()
|
| 1105 |
+
);
|
| 1106 |
+
body.position.y = 1.3;
|
| 1107 |
+
const aura = new THREE.Mesh(
|
| 1108 |
+
new THREE.RingGeometry(0.52, 0.82, 32),
|
| 1109 |
+
new THREE.MeshBasicMaterial({
|
| 1110 |
+
color: glow,
|
| 1111 |
+
transparent: true,
|
| 1112 |
+
opacity: 0.45,
|
| 1113 |
+
side: THREE.DoubleSide,
|
| 1114 |
+
blending: THREE.AdditiveBlending,
|
| 1115 |
+
depthWrite: false,
|
| 1116 |
+
})
|
| 1117 |
+
);
|
| 1118 |
+
aura.rotation.x = -Math.PI / 2;
|
| 1119 |
+
aura.position.y = 0.04;
|
| 1120 |
+
const label = new THREE.Mesh(
|
| 1121 |
+
new THREE.PlaneGeometry(2.5, 0.62),
|
| 1122 |
+
new THREE.MeshBasicMaterial({
|
| 1123 |
+
map: makeNameTexture(v.display_name),
|
| 1124 |
+
transparent: true,
|
| 1125 |
+
depthWrite: false,
|
| 1126 |
+
})
|
| 1127 |
+
);
|
| 1128 |
+
label.position.y = 2.22;
|
| 1129 |
+
group.add(aura, body, label);
|
| 1130 |
+
group.position.set(Number(v.x) || 0, 0, Number(v.z) || 0);
|
| 1131 |
+
group.userData = {
|
| 1132 |
+
targetX: group.position.x,
|
| 1133 |
+
targetZ: group.position.z,
|
| 1134 |
+
seenAt: performance.now(),
|
| 1135 |
+
body,
|
| 1136 |
+
aura,
|
| 1137 |
+
label,
|
| 1138 |
+
};
|
| 1139 |
+
scene.add(group);
|
| 1140 |
+
return group;
|
| 1141 |
+
}
|
| 1142 |
+
function updateRemoteVisitors(list) {
|
| 1143 |
+
const now = performance.now();
|
| 1144 |
+
const seen = new Set();
|
| 1145 |
+
for (const v of list || []) {
|
| 1146 |
+
if (!v.session_id || v.session_id === visitorId) continue;
|
| 1147 |
+
seen.add(v.session_id);
|
| 1148 |
+
let group = remoteVisitors.get(v.session_id);
|
| 1149 |
+
if (!group) {
|
| 1150 |
+
group = createRemoteVisitor(v);
|
| 1151 |
+
remoteVisitors.set(v.session_id, group);
|
| 1152 |
+
}
|
| 1153 |
+
group.userData.targetX = Number(v.x) || 0;
|
| 1154 |
+
group.userData.targetZ = Number(v.z) || 0;
|
| 1155 |
+
group.userData.seenAt = now;
|
| 1156 |
+
}
|
| 1157 |
+
for (const [id, group] of remoteVisitors) {
|
| 1158 |
+
if (!seen.has(id) && now - group.userData.seenAt > 26000) {
|
| 1159 |
+
scene.remove(group);
|
| 1160 |
+
remoteVisitors.delete(id);
|
| 1161 |
+
}
|
| 1162 |
+
}
|
| 1163 |
+
const count = remoteVisitors.size;
|
| 1164 |
+
if (count) {
|
| 1165 |
+
multiplayerHud.innerHTML = `<b>${count}</b> other visitor${count === 1 ? '' : 's'} walking here`;
|
| 1166 |
+
multiplayerHud.classList.add('show');
|
| 1167 |
+
} else {
|
| 1168 |
+
multiplayerHud.classList.remove('show');
|
| 1169 |
+
}
|
| 1170 |
+
}
|
| 1171 |
+
function gradioApiUrl(apiName) {
|
| 1172 |
+
const root = (() => {
|
| 1173 |
+
const p = location.pathname || '/';
|
| 1174 |
+
const fileIdx = p.indexOf('/gradio_api/file=');
|
| 1175 |
+
if (fileIdx >= 0) return p.slice(0, fileIdx) || '/';
|
| 1176 |
+
try {
|
| 1177 |
+
const pp = window.parent?.location?.pathname || '/';
|
| 1178 |
+
const fi = pp.indexOf('/gradio_api/file=');
|
| 1179 |
+
if (fi >= 0) return pp.slice(0, fi) || '/';
|
| 1180 |
+
return pp.endsWith('/') ? pp : pp + '/';
|
| 1181 |
+
} catch (_) { return '/'; }
|
| 1182 |
+
})();
|
| 1183 |
+
return `${root}gradio_api/run/${apiName}`;
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
const waveEmotes = [];
|
| 1187 |
+
function tryWave() {
|
| 1188 |
+
let nearVisitor = false;
|
| 1189 |
+
for (const group of remoteVisitors.values()) {
|
| 1190 |
+
const dx = group.position.x - camera.position.x;
|
| 1191 |
+
const dz = group.position.z - camera.position.z;
|
| 1192 |
+
if (Math.hypot(dx, dz) < 6) nearVisitor = true;
|
| 1193 |
+
}
|
| 1194 |
+
const el = document.createElement('div');
|
| 1195 |
+
el.textContent = nearVisitor ? '👋 Hello, fellow walker!' : '👋';
|
| 1196 |
+
el.style.cssText = 'position:fixed;left:50%;top:42%;transform:translateX(-50%);z-index:12;pointer-events:none;'
|
| 1197 |
+
+ 'padding:0.4rem 0.9rem;border-radius:999px;font-size:0.82rem;color:#f0d9a0;'
|
| 1198 |
+
+ 'background:rgba(8,12,10,0.82);border:1px solid rgba(212,168,75,0.4);'
|
| 1199 |
+
+ 'animation:waveFade 1.8s ease forwards;font-family:Georgia,serif;';
|
| 1200 |
+
document.body.appendChild(el);
|
| 1201 |
+
setTimeout(() => el.remove(), 1900);
|
| 1202 |
+
waveEmotes.push({ t: performance.now(), near: nearVisitor });
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
+
async function syncPresence() {
|
| 1206 |
+
try {
|
| 1207 |
+
const res = await fetch(gradioApiUrl('diorama_presence'), {
|
| 1208 |
+
method: 'POST',
|
| 1209 |
+
headers: {'Content-Type': 'application/json'},
|
| 1210 |
+
body: JSON.stringify({
|
| 1211 |
+
data: [
|
| 1212 |
+
String(loc.id || ''),
|
| 1213 |
+
visitorId,
|
| 1214 |
+
camera.position.x.toFixed(2),
|
| 1215 |
+
camera.position.z.toFixed(2),
|
| 1216 |
+
yaw.toFixed(4),
|
| 1217 |
+
]
|
| 1218 |
+
})
|
| 1219 |
+
});
|
| 1220 |
+
if (!res.ok) return;
|
| 1221 |
+
const json = await res.json();
|
| 1222 |
+
const payload = json.data && json.data[0];
|
| 1223 |
+
updateRemoteVisitors(payload && payload.visitors ? payload.visitors : []);
|
| 1224 |
+
} catch (_) {}
|
| 1225 |
+
}
|
| 1226 |
+
setInterval(syncPresence, 800);
|
| 1227 |
+
setTimeout(syncPresence, 400);
|
| 1228 |
+
|
| 1229 |
// ═══════════════════════════════════════════
|
| 1230 |
// AUDIO — per-location pad synthesizer
|
| 1231 |
// ═══════════════════════════════════════════
|
|
|
|
| 1445 |
// ── Soul chat via Gradio API ──
|
| 1446 |
async function askSoul(soulId, message) {
|
| 1447 |
try {
|
| 1448 |
+
const res = await fetch(gradioApiUrl('chat_with_soul'), {
|
| 1449 |
method: 'POST',
|
| 1450 |
headers: {'Content-Type':'application/json'},
|
| 1451 |
body: JSON.stringify({ data: [soulId, message] })
|
|
|
|
| 1549 |
chatHistory.appendChild(vMsg);
|
| 1550 |
// Call quest API
|
| 1551 |
try {
|
| 1552 |
+
const res=await fetch(gradioApiUrl('assign_quest'),{
|
| 1553 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1554 |
body:JSON.stringify({data:[soulId, questTitle]})
|
| 1555 |
});
|
|
|
|
| 1838 |
else if(ea.type==='portalLight'){ea.light.intensity=0.7+Math.sin(t*2.2+ea.phase)*0.3;}
|
| 1839 |
}
|
| 1840 |
|
| 1841 |
+
for (const group of remoteVisitors.values()) {
|
| 1842 |
+
const u = group.userData;
|
| 1843 |
+
const lerp = Math.min(1, dt * 8);
|
| 1844 |
+
group.position.x += (u.targetX - group.position.x) * lerp;
|
| 1845 |
+
group.position.z += (u.targetZ - group.position.z) * lerp;
|
| 1846 |
+
const pulse = 0.9 + Math.sin(t * 3.2 + group.position.x) * 0.12;
|
| 1847 |
+
u.body.scale.setScalar(pulse);
|
| 1848 |
+
u.aura.rotation.z += dt * 0.9;
|
| 1849 |
+
u.aura.material.opacity = 0.3 + Math.sin(t * 2.1) * 0.1;
|
| 1850 |
+
u.label.lookAt(camera.position.x, u.label.position.y + group.position.y, camera.position.z);
|
| 1851 |
+
}
|
| 1852 |
+
|
| 1853 |
// Particles
|
| 1854 |
const mp=motes.geometry.attributes.position;
|
| 1855 |
for(let i=0;i<moteN;i++){
|
|
|
|
| 1875 |
window.addEventListener('message', e => {
|
| 1876 |
if (e.data === 'diorama-resize') resizeRenderer();
|
| 1877 |
if (e.data?.type === 'aether_key') {
|
| 1878 |
+
const code = e.data.code;
|
| 1879 |
+
if (!code) return;
|
| 1880 |
+
if (e.data.action === 'down') {
|
| 1881 |
+
activateControls();
|
| 1882 |
+
pressKey(code);
|
| 1883 |
+
} else {
|
| 1884 |
+
releaseKey(code);
|
| 1885 |
+
}
|
| 1886 |
+
}
|
| 1887 |
+
if (e.data?.type === 'aether_activate') {
|
| 1888 |
+
activateControls();
|
| 1889 |
+
grabFocus();
|
| 1890 |
+
enterFPS();
|
| 1891 |
}
|
|
|
|
| 1892 |
});
|
| 1893 |
</script>
|
| 1894 |
</body>
|
ui/hero.py
CHANGED
|
@@ -13,9 +13,19 @@ def render_hero_banner() -> str:
|
|
| 13 |
events = state.get("total_events", 0)
|
| 14 |
interactions = state.get("total_interactions", 0)
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
return f"""
|
| 17 |
<div class="hero-banner">
|
| 18 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
<div class="hero-statusbar">
|
| 20 |
<span class="hero-status-live"><span class="live-dot"></span> The world is awake</span>
|
| 21 |
<span class="hero-stat"><b>Day {day}</b></span>
|
|
|
|
| 13 |
events = state.get("total_events", 0)
|
| 14 |
interactions = state.get("total_interactions", 0)
|
| 15 |
|
| 16 |
+
particles = "".join(
|
| 17 |
+
f'<span class="hero-particle" style="left:{(i * 17 + 7) % 96}%;'
|
| 18 |
+
f'animation-delay:{(i * 0.37) % 5:.2f}s;'
|
| 19 |
+
f'animation-duration:{3.5 + (i % 4) * 0.8:.1f}s"></span>'
|
| 20 |
+
for i in range(18)
|
| 21 |
+
)
|
| 22 |
return f"""
|
| 23 |
<div class="hero-banner">
|
| 24 |
+
<div class="hero-image-wrap">
|
| 25 |
+
<img src="{assets.banner_url()}" alt="Aether Garden — The World That Remembers" class="hero-image"/>
|
| 26 |
+
<div class="hero-particles" aria-hidden="true">{particles}</div>
|
| 27 |
+
<div class="hero-shimmer" aria-hidden="true"></div>
|
| 28 |
+
</div>
|
| 29 |
<div class="hero-statusbar">
|
| 30 |
<span class="hero-status-live"><span class="live-dot"></span> The world is awake</span>
|
| 31 |
<span class="hero-stat"><b>Day {day}</b></span>
|
ui/map.py
CHANGED
|
@@ -106,10 +106,12 @@ def render_world_map(selected_location_id: int | None = None) -> str:
|
|
| 106 |
f'<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{color}"/>'
|
| 107 |
)
|
| 108 |
|
| 109 |
-
for path in LOCATION_PATHS:
|
| 110 |
svg_parts.append(
|
| 111 |
-
f'<path d="{path}" fill="none"
|
| 112 |
-
f'stroke
|
|
|
|
|
|
|
| 113 |
)
|
| 114 |
|
| 115 |
for loc in locations:
|
|
@@ -117,7 +119,7 @@ def render_world_map(selected_location_id: int | None = None) -> str:
|
|
| 117 |
y = loc["map_y"] / 100 * 600
|
| 118 |
is_affected = loc["slug"] in affected_slugs
|
| 119 |
is_selected = selected_location_id == loc["id"]
|
| 120 |
-
pulse_class = " location-pulse" if is_affected else ""
|
| 121 |
selected_class = " location-selected" if is_selected else ""
|
| 122 |
|
| 123 |
entities = get_entities_by_location(loc["id"], limit=8)
|
|
@@ -131,11 +133,11 @@ def render_world_map(selected_location_id: int | None = None) -> str:
|
|
| 131 |
|
| 132 |
filter_id = "pulse-glow" if is_affected else "glow"
|
| 133 |
svg_parts.append(
|
| 134 |
-
f'<circle r="
|
| 135 |
-
f'filter="url(#{filter_id})"/>'
|
| 136 |
)
|
| 137 |
svg_parts.append(
|
| 138 |
-
f'<circle r="
|
| 139 |
f'stroke="{loc["glow_color"]}" stroke-width="2" class="location-circle"/>'
|
| 140 |
)
|
| 141 |
svg_parts.append(
|
|
|
|
| 106 |
f'<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{color}"/>'
|
| 107 |
)
|
| 108 |
|
| 109 |
+
for i, path in enumerate(LOCATION_PATHS):
|
| 110 |
svg_parts.append(
|
| 111 |
+
f'<path class="realm-path" d="{path}" fill="none" '
|
| 112 |
+
f'stroke="rgba(212,168,75,0.22)" stroke-width="2.5" '
|
| 113 |
+
f'stroke-dasharray="8,10" stroke-linecap="round" '
|
| 114 |
+
f'style="animation-delay:{i * 0.4:.1f}s"/>'
|
| 115 |
)
|
| 116 |
|
| 117 |
for loc in locations:
|
|
|
|
| 119 |
y = loc["map_y"] / 100 * 600
|
| 120 |
is_affected = loc["slug"] in affected_slugs
|
| 121 |
is_selected = selected_location_id == loc["id"]
|
| 122 |
+
pulse_class = " location-pulse" if is_affected else " location-glow"
|
| 123 |
selected_class = " location-selected" if is_selected else ""
|
| 124 |
|
| 125 |
entities = get_entities_by_location(loc["id"], limit=8)
|
|
|
|
| 133 |
|
| 134 |
filter_id = "pulse-glow" if is_affected else "glow"
|
| 135 |
svg_parts.append(
|
| 136 |
+
f'<circle r="44" fill="{loc["glow_color"]}" opacity="0.1" '
|
| 137 |
+
f'class="location-aura" filter="url(#{filter_id})"/>'
|
| 138 |
)
|
| 139 |
svg_parts.append(
|
| 140 |
+
f'<circle r="36" fill="{loc["glow_color"]}" opacity="0.2" '
|
| 141 |
f'stroke="{loc["glow_color"]}" stroke-width="2" class="location-circle"/>'
|
| 142 |
)
|
| 143 |
svg_parts.append(
|
ui/styles.css
CHANGED
|
@@ -13,6 +13,11 @@
|
|
| 13 |
--realm-forest: #2a4a3a;
|
| 14 |
--realm-border: rgba(212, 168, 75, 0.18);
|
| 15 |
--realm-border-strong: rgba(212, 168, 75, 0.4);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
/* Gradio overrides */
|
|
@@ -39,11 +44,66 @@ footer { display: none !important; }
|
|
| 39 |
box-shadow: 0 10px 50px rgba(0,0,0,0.55), 0 0 70px rgba(212,168,75,0.07);
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
.hero-image {
|
| 43 |
width: 100%;
|
| 44 |
height: auto;
|
| 45 |
display: block;
|
| 46 |
object-fit: cover;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
.hero-statusbar {
|
|
@@ -362,10 +422,29 @@ footer { display: none !important; }
|
|
| 362 |
transform: scale(1.08);
|
| 363 |
}
|
| 364 |
|
| 365 |
-
.location-pulse .location-circle
|
|
|
|
| 366 |
animation: pulse-glow 2.5s ease-in-out infinite;
|
| 367 |
}
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
.location-selected .location-circle {
|
| 370 |
stroke-width: 3 !important;
|
| 371 |
opacity: 0.6 !important;
|
|
@@ -1269,14 +1348,33 @@ footer { display: none !important; }
|
|
| 1269 |
|
| 1270 |
/* 3D diorama iframe */
|
| 1271 |
.diorama-wrap {
|
|
|
|
| 1272 |
margin: 1.25rem 0;
|
| 1273 |
-
border:
|
| 1274 |
-
border-radius:
|
| 1275 |
overflow: hidden;
|
| 1276 |
background: var(--realm-bg-secondary);
|
| 1277 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
}
|
| 1279 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1280 |
.diorama-header {
|
| 1281 |
padding: 1rem 1.25rem;
|
| 1282 |
border-bottom: 1px solid var(--realm-border);
|
|
@@ -1658,3 +1756,336 @@ footer { display: none !important; }
|
|
| 1658 |
}
|
| 1659 |
.presence-count { color: var(--realm-accent-gold); }
|
| 1660 |
.presence-arrivals{ color: var(--realm-text-secondary); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
--realm-forest: #2a4a3a;
|
| 14 |
--realm-border: rgba(212, 168, 75, 0.18);
|
| 15 |
--realm-border-strong: rgba(212, 168, 75, 0.4);
|
| 16 |
+
--realm-glass: rgba(12, 20, 16, 0.72);
|
| 17 |
+
--realm-glass-border: rgba(212, 168, 75, 0.22);
|
| 18 |
+
--realm-glass-glow: rgba(212, 168, 75, 0.08);
|
| 19 |
+
--realm-line-height: 1.65;
|
| 20 |
+
--realm-letter-spacing: 0.02em;
|
| 21 |
}
|
| 22 |
|
| 23 |
/* Gradio overrides */
|
|
|
|
| 44 |
box-shadow: 0 10px 50px rgba(0,0,0,0.55), 0 0 70px rgba(212,168,75,0.07);
|
| 45 |
}
|
| 46 |
|
| 47 |
+
.hero-image-wrap {
|
| 48 |
+
position: relative;
|
| 49 |
+
overflow: hidden;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
.hero-image {
|
| 53 |
width: 100%;
|
| 54 |
height: auto;
|
| 55 |
display: block;
|
| 56 |
object-fit: cover;
|
| 57 |
+
animation: heroParallax 18s ease-in-out infinite alternate;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
@keyframes heroParallax {
|
| 61 |
+
from { transform: scale(1) translateY(0); }
|
| 62 |
+
to { transform: scale(1.04) translateY(-1%); }
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.hero-particles {
|
| 66 |
+
position: absolute;
|
| 67 |
+
inset: 0;
|
| 68 |
+
pointer-events: none;
|
| 69 |
+
overflow: hidden;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.hero-particle {
|
| 73 |
+
position: absolute;
|
| 74 |
+
bottom: -4px;
|
| 75 |
+
width: 3px;
|
| 76 |
+
height: 3px;
|
| 77 |
+
border-radius: 50%;
|
| 78 |
+
background: rgba(212, 168, 75, 0.55);
|
| 79 |
+
box-shadow: 0 0 8px rgba(212, 168, 75, 0.4);
|
| 80 |
+
animation: heroParticleRise linear infinite;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
@keyframes heroParticleRise {
|
| 84 |
+
0% { transform: translateY(0) scale(0.6); opacity: 0; }
|
| 85 |
+
10% { opacity: 0.8; }
|
| 86 |
+
90% { opacity: 0.5; }
|
| 87 |
+
100% { transform: translateY(-320px) scale(1.2); opacity: 0; }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.hero-shimmer {
|
| 91 |
+
position: absolute;
|
| 92 |
+
inset: 0;
|
| 93 |
+
background: linear-gradient(
|
| 94 |
+
105deg,
|
| 95 |
+
transparent 40%,
|
| 96 |
+
rgba(212, 168, 75, 0.06) 50%,
|
| 97 |
+
transparent 60%
|
| 98 |
+
);
|
| 99 |
+
background-size: 200% 100%;
|
| 100 |
+
animation: heroShimmer 8s ease-in-out infinite;
|
| 101 |
+
pointer-events: none;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
@keyframes heroShimmer {
|
| 105 |
+
0%, 100% { background-position: 200% 0; }
|
| 106 |
+
50% { background-position: -200% 0; }
|
| 107 |
}
|
| 108 |
|
| 109 |
.hero-statusbar {
|
|
|
|
| 422 |
transform: scale(1.08);
|
| 423 |
}
|
| 424 |
|
| 425 |
+
.location-pulse .location-circle,
|
| 426 |
+
.location-glow .location-circle {
|
| 427 |
animation: pulse-glow 2.5s ease-in-out infinite;
|
| 428 |
}
|
| 429 |
|
| 430 |
+
.location-glow .location-aura {
|
| 431 |
+
animation: aura-breathe 4s ease-in-out infinite;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.realm-path {
|
| 435 |
+
animation: path-shimmer 6s ease-in-out infinite;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
@keyframes aura-breathe {
|
| 439 |
+
0%, 100% { opacity: 0.08; r: 44; }
|
| 440 |
+
50% { opacity: 0.16; }
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
@keyframes path-shimmer {
|
| 444 |
+
0%, 100% { stroke-opacity: 0.18; }
|
| 445 |
+
50% { stroke-opacity: 0.38; }
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
.location-selected .location-circle {
|
| 449 |
stroke-width: 3 !important;
|
| 450 |
opacity: 0.6 !important;
|
|
|
|
| 1348 |
|
| 1349 |
/* 3D diorama iframe */
|
| 1350 |
.diorama-wrap {
|
| 1351 |
+
position: relative;
|
| 1352 |
margin: 1.25rem 0;
|
| 1353 |
+
border: 2px solid var(--realm-border-strong);
|
| 1354 |
+
border-radius: 16px;
|
| 1355 |
overflow: hidden;
|
| 1356 |
background: var(--realm-bg-secondary);
|
| 1357 |
+
box-shadow:
|
| 1358 |
+
0 14px 48px rgba(0,0,0,0.55),
|
| 1359 |
+
0 0 60px var(--realm-glass-glow),
|
| 1360 |
+
inset 0 0 0 1px rgba(255,255,255,0.04);
|
| 1361 |
+
padding: 6px;
|
| 1362 |
}
|
| 1363 |
|
| 1364 |
+
.diorama-wrap::before,
|
| 1365 |
+
.diorama-wrap::after {
|
| 1366 |
+
content: '◆';
|
| 1367 |
+
position: absolute;
|
| 1368 |
+
z-index: 2;
|
| 1369 |
+
color: var(--realm-accent-gold);
|
| 1370 |
+
font-size: 0.65rem;
|
| 1371 |
+
opacity: 0.45;
|
| 1372 |
+
pointer-events: none;
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
.diorama-wrap::before { top: 10px; left: 14px; }
|
| 1376 |
+
.diorama-wrap::after { top: 10px; right: 14px; }
|
| 1377 |
+
|
| 1378 |
.diorama-header {
|
| 1379 |
padding: 1rem 1.25rem;
|
| 1380 |
border-bottom: 1px solid var(--realm-border);
|
|
|
|
| 1756 |
}
|
| 1757 |
.presence-count { color: var(--realm-accent-gold); }
|
| 1758 |
.presence-arrivals{ color: var(--realm-text-secondary); }
|
| 1759 |
+
|
| 1760 |
+
/* 2026 game-shell refresh */
|
| 1761 |
+
:root {
|
| 1762 |
+
--realm-bg-primary: #070908;
|
| 1763 |
+
--realm-bg-secondary: #101714;
|
| 1764 |
+
--realm-bg-tertiary: #17231d;
|
| 1765 |
+
--realm-surface: rgba(18, 24, 22, 0.82);
|
| 1766 |
+
--realm-surface-strong: rgba(24, 33, 29, 0.94);
|
| 1767 |
+
--realm-text-primary: #f5efe1;
|
| 1768 |
+
--realm-text-secondary: #c7beaa;
|
| 1769 |
+
--realm-text-tertiary: #899284;
|
| 1770 |
+
--realm-accent-gold: #e3b85d;
|
| 1771 |
+
--realm-accent-violet: #a98add;
|
| 1772 |
+
--realm-accent-ember: #ef835d;
|
| 1773 |
+
--realm-accent-teal: #6ec9bc;
|
| 1774 |
+
--realm-border: rgba(227, 184, 93, 0.16);
|
| 1775 |
+
--realm-border-strong: rgba(227, 184, 93, 0.38);
|
| 1776 |
+
}
|
| 1777 |
+
|
| 1778 |
+
body,
|
| 1779 |
+
.gradio-container {
|
| 1780 |
+
background:
|
| 1781 |
+
radial-gradient(circle at 12% 0%, rgba(110, 201, 188, 0.08), transparent 28rem),
|
| 1782 |
+
radial-gradient(circle at 88% 8%, rgba(169, 138, 221, 0.09), transparent 32rem),
|
| 1783 |
+
linear-gradient(180deg, #070908 0%, #0d1110 46%, #070908 100%) !important;
|
| 1784 |
+
color: var(--realm-text-primary) !important;
|
| 1785 |
+
}
|
| 1786 |
+
|
| 1787 |
+
.gradio-container {
|
| 1788 |
+
max-width: 1480px !important;
|
| 1789 |
+
padding: 1rem 1.25rem 2rem !important;
|
| 1790 |
+
}
|
| 1791 |
+
|
| 1792 |
+
.gradio-container button,
|
| 1793 |
+
.gradio-container .gr-button {
|
| 1794 |
+
border-radius: 8px !important;
|
| 1795 |
+
border: 1px solid var(--realm-border-strong) !important;
|
| 1796 |
+
background: linear-gradient(180deg, rgba(227,184,93,0.18), rgba(227,184,93,0.08)) !important;
|
| 1797 |
+
color: var(--realm-text-primary) !important;
|
| 1798 |
+
box-shadow: 0 10px 26px rgba(0,0,0,0.28) !important;
|
| 1799 |
+
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease !important;
|
| 1800 |
+
}
|
| 1801 |
+
|
| 1802 |
+
.gradio-container button:hover,
|
| 1803 |
+
.gradio-container .gr-button:hover {
|
| 1804 |
+
transform: translateY(-1px);
|
| 1805 |
+
border-color: rgba(227,184,93,0.64) !important;
|
| 1806 |
+
background: linear-gradient(180deg, rgba(227,184,93,0.28), rgba(239,131,93,0.12)) !important;
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
.gradio-container textarea,
|
| 1810 |
+
.gradio-container input,
|
| 1811 |
+
.gradio-container select {
|
| 1812 |
+
border-radius: 8px !important;
|
| 1813 |
+
border-color: rgba(227,184,93,0.22) !important;
|
| 1814 |
+
background: rgba(7,9,8,0.72) !important;
|
| 1815 |
+
color: var(--realm-text-primary) !important;
|
| 1816 |
+
}
|
| 1817 |
+
|
| 1818 |
+
.gradio-container textarea:focus,
|
| 1819 |
+
.gradio-container input:focus {
|
| 1820 |
+
border-color: rgba(110,201,188,0.66) !important;
|
| 1821 |
+
box-shadow: 0 0 0 2px rgba(110,201,188,0.16) !important;
|
| 1822 |
+
}
|
| 1823 |
+
|
| 1824 |
+
.realm-header-wrap {
|
| 1825 |
+
display: block;
|
| 1826 |
+
margin-bottom: 1rem;
|
| 1827 |
+
}
|
| 1828 |
+
|
| 1829 |
+
.hero-banner {
|
| 1830 |
+
border-radius: 8px;
|
| 1831 |
+
border-color: rgba(227,184,93,0.28);
|
| 1832 |
+
box-shadow:
|
| 1833 |
+
0 22px 70px rgba(0,0,0,0.52),
|
| 1834 |
+
0 0 0 1px rgba(255,255,255,0.03) inset;
|
| 1835 |
+
}
|
| 1836 |
+
|
| 1837 |
+
.hero-statusbar,
|
| 1838 |
+
.diorama-header,
|
| 1839 |
+
.realm-pulse,
|
| 1840 |
+
.current-event,
|
| 1841 |
+
.location-panel,
|
| 1842 |
+
.book-container,
|
| 1843 |
+
.entity-card,
|
| 1844 |
+
.room-view,
|
| 1845 |
+
.summon-section,
|
| 1846 |
+
.world-vignette,
|
| 1847 |
+
.featured-card,
|
| 1848 |
+
.entity-grid-card,
|
| 1849 |
+
.pulse-card {
|
| 1850 |
+
background:
|
| 1851 |
+
linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0)),
|
| 1852 |
+
var(--realm-surface) !important;
|
| 1853 |
+
border-color: var(--realm-border) !important;
|
| 1854 |
+
border-radius: 8px !important;
|
| 1855 |
+
box-shadow: 0 12px 36px rgba(0,0,0,0.28);
|
| 1856 |
+
}
|
| 1857 |
+
|
| 1858 |
+
.realm-tabs {
|
| 1859 |
+
position: sticky;
|
| 1860 |
+
top: 0;
|
| 1861 |
+
z-index: 15;
|
| 1862 |
+
background: rgba(7,9,8,0.84);
|
| 1863 |
+
backdrop-filter: blur(14px);
|
| 1864 |
+
border: 1px solid rgba(227,184,93,0.10) !important;
|
| 1865 |
+
border-radius: 8px;
|
| 1866 |
+
padding: 0.25rem;
|
| 1867 |
+
margin-bottom: 1rem;
|
| 1868 |
+
}
|
| 1869 |
+
|
| 1870 |
+
.realm-tabs button {
|
| 1871 |
+
border-radius: 6px !important;
|
| 1872 |
+
min-height: 2.35rem !important;
|
| 1873 |
+
letter-spacing: 0.08em !important;
|
| 1874 |
+
}
|
| 1875 |
+
|
| 1876 |
+
.realm-tabs button.selected {
|
| 1877 |
+
background: rgba(227,184,93,0.14) !important;
|
| 1878 |
+
border-bottom: none !important;
|
| 1879 |
+
box-shadow: inset 0 0 0 1px rgba(227,184,93,0.22) !important;
|
| 1880 |
+
}
|
| 1881 |
+
|
| 1882 |
+
.presence-bar {
|
| 1883 |
+
position: sticky;
|
| 1884 |
+
top: 0;
|
| 1885 |
+
z-index: 20;
|
| 1886 |
+
justify-content: center;
|
| 1887 |
+
border: 1px solid rgba(110,201,188,0.18);
|
| 1888 |
+
border-radius: 999px;
|
| 1889 |
+
margin: 0.2rem auto 0.75rem;
|
| 1890 |
+
max-width: 820px;
|
| 1891 |
+
background: rgba(7,9,8,0.74);
|
| 1892 |
+
backdrop-filter: blur(12px);
|
| 1893 |
+
}
|
| 1894 |
+
|
| 1895 |
+
.realm-map {
|
| 1896 |
+
border-radius: 8px;
|
| 1897 |
+
border-color: rgba(227,184,93,0.34);
|
| 1898 |
+
box-shadow:
|
| 1899 |
+
0 24px 80px rgba(0,0,0,0.34),
|
| 1900 |
+
inset 0 0 70px rgba(110,201,188,0.08);
|
| 1901 |
+
}
|
| 1902 |
+
|
| 1903 |
+
.location-node {
|
| 1904 |
+
filter: drop-shadow(0 7px 12px rgba(0,0,0,0.36));
|
| 1905 |
+
}
|
| 1906 |
+
|
| 1907 |
+
.location-selected .location-circle {
|
| 1908 |
+
stroke: var(--realm-accent-teal) !important;
|
| 1909 |
+
}
|
| 1910 |
+
|
| 1911 |
+
.diorama-wrap {
|
| 1912 |
+
border-radius: 8px;
|
| 1913 |
+
border-color: rgba(110,201,188,0.24);
|
| 1914 |
+
box-shadow:
|
| 1915 |
+
0 24px 76px rgba(0,0,0,0.48),
|
| 1916 |
+
0 0 0 1px rgba(255,255,255,0.025) inset;
|
| 1917 |
+
}
|
| 1918 |
+
|
| 1919 |
+
.diorama-frame {
|
| 1920 |
+
height: min(82vh, 760px);
|
| 1921 |
+
min-height: 620px;
|
| 1922 |
+
}
|
| 1923 |
+
|
| 1924 |
+
.diorama-label,
|
| 1925 |
+
.section-title,
|
| 1926 |
+
.summon-title,
|
| 1927 |
+
.event-label,
|
| 1928 |
+
.pulse-title,
|
| 1929 |
+
.book-title,
|
| 1930 |
+
.location-panel-special {
|
| 1931 |
+
color: var(--realm-accent-gold) !important;
|
| 1932 |
+
}
|
| 1933 |
+
|
| 1934 |
+
.section-desc,
|
| 1935 |
+
.diorama-hint,
|
| 1936 |
+
.summon-hint {
|
| 1937 |
+
color: var(--realm-text-tertiary) !important;
|
| 1938 |
+
}
|
| 1939 |
+
|
| 1940 |
+
.featured-card:hover,
|
| 1941 |
+
.entity-grid-card:hover,
|
| 1942 |
+
.loc-card:hover {
|
| 1943 |
+
border-color: rgba(110,201,188,0.42) !important;
|
| 1944 |
+
box-shadow: 0 18px 48px rgba(0,0,0,0.46), 0 0 28px rgba(110,201,188,0.10);
|
| 1945 |
+
}
|
| 1946 |
+
|
| 1947 |
+
.loc-card,
|
| 1948 |
+
.places-gallery button.thumbnail-item img,
|
| 1949 |
+
.souls-gallery button.thumbnail-item img {
|
| 1950 |
+
border-radius: 8px;
|
| 1951 |
+
}
|
| 1952 |
+
|
| 1953 |
+
.oracle-summoning {
|
| 1954 |
+
border-radius: 8px;
|
| 1955 |
+
background:
|
| 1956 |
+
radial-gradient(circle at 50% 0%, rgba(110,201,188,0.14), transparent 42%),
|
| 1957 |
+
radial-gradient(circle at 70% 20%, rgba(169,138,221,0.12), transparent 36%),
|
| 1958 |
+
rgba(12,18,16,0.88);
|
| 1959 |
+
}
|
| 1960 |
+
|
| 1961 |
+
.tick-result {
|
| 1962 |
+
border-radius: 8px;
|
| 1963 |
+
background:
|
| 1964 |
+
linear-gradient(135deg, rgba(110,201,188,0.12), rgba(227,184,93,0.07)),
|
| 1965 |
+
rgba(12,18,16,0.9);
|
| 1966 |
+
}
|
| 1967 |
+
|
| 1968 |
+
@media (max-width: 768px) {
|
| 1969 |
+
.gradio-container {
|
| 1970 |
+
padding: 0.75rem !important;
|
| 1971 |
+
}
|
| 1972 |
+
.realm-tabs {
|
| 1973 |
+
position: relative;
|
| 1974 |
+
}
|
| 1975 |
+
.presence-bar {
|
| 1976 |
+
position: relative;
|
| 1977 |
+
border-radius: 8px;
|
| 1978 |
+
}
|
| 1979 |
+
.diorama-frame {
|
| 1980 |
+
height: min(64vh, 520px);
|
| 1981 |
+
min-height: 390px;
|
| 1982 |
+
}
|
| 1983 |
+
.diorama-wrap::before,
|
| 1984 |
+
.diorama-wrap::after {
|
| 1985 |
+
display: none;
|
| 1986 |
+
}
|
| 1987 |
+
}
|
| 1988 |
+
|
| 1989 |
+
/* ── Premium glassmorphism & polish ── */
|
| 1990 |
+
.entity-card,
|
| 1991 |
+
.current-event,
|
| 1992 |
+
.book-container,
|
| 1993 |
+
.location-panel,
|
| 1994 |
+
.realm-pulse,
|
| 1995 |
+
.world-vignette,
|
| 1996 |
+
.featured-card {
|
| 1997 |
+
backdrop-filter: blur(12px);
|
| 1998 |
+
-webkit-backdrop-filter: blur(12px);
|
| 1999 |
+
box-shadow:
|
| 2000 |
+
0 12px 40px rgba(0,0,0,0.35),
|
| 2001 |
+
inset 0 1px 0 rgba(255,255,255,0.05);
|
| 2002 |
+
}
|
| 2003 |
+
|
| 2004 |
+
.section-title,
|
| 2005 |
+
.book-title,
|
| 2006 |
+
.pulse-title,
|
| 2007 |
+
.event-label,
|
| 2008 |
+
.summon-title {
|
| 2009 |
+
background: linear-gradient(90deg, #d4a84b, #f0d9a0, #d4a84b);
|
| 2010 |
+
background-size: 200% auto;
|
| 2011 |
+
-webkit-background-clip: text;
|
| 2012 |
+
background-clip: text;
|
| 2013 |
+
-webkit-text-fill-color: transparent;
|
| 2014 |
+
animation: titleShimmer 6s linear infinite;
|
| 2015 |
+
}
|
| 2016 |
+
|
| 2017 |
+
@keyframes titleShimmer {
|
| 2018 |
+
to { background-position: 200% center; }
|
| 2019 |
+
}
|
| 2020 |
+
|
| 2021 |
+
.entity-card,
|
| 2022 |
+
.pulse-card,
|
| 2023 |
+
.featured-card {
|
| 2024 |
+
animation: cardEntrance 0.55s ease-out both;
|
| 2025 |
+
}
|
| 2026 |
+
|
| 2027 |
+
@keyframes cardEntrance {
|
| 2028 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 2029 |
+
to { opacity: 1; transform: translateY(0); }
|
| 2030 |
+
}
|
| 2031 |
+
|
| 2032 |
+
.gradio-container button.primary,
|
| 2033 |
+
.gradio-container button[class*="primary"],
|
| 2034 |
+
.gradio-container .primary {
|
| 2035 |
+
background: linear-gradient(135deg, rgba(212,168,75,0.25), rgba(212,168,75,0.12)) !important;
|
| 2036 |
+
border: 1px solid rgba(212,168,75,0.45) !important;
|
| 2037 |
+
color: #f0d9a0 !important;
|
| 2038 |
+
transition: box-shadow 0.25s, transform 0.2s, background 0.25s !important;
|
| 2039 |
+
}
|
| 2040 |
+
|
| 2041 |
+
.gradio-container button.primary:hover,
|
| 2042 |
+
.gradio-container button[class*="primary"]:hover {
|
| 2043 |
+
box-shadow: 0 0 24px rgba(212,168,75,0.28) !important;
|
| 2044 |
+
transform: translateY(-1px);
|
| 2045 |
+
}
|
| 2046 |
+
|
| 2047 |
+
.gradio-container button,
|
| 2048 |
+
.gradio-container .tab-nav button {
|
| 2049 |
+
transition: box-shadow 0.2s, border-color 0.2s !important;
|
| 2050 |
+
}
|
| 2051 |
+
|
| 2052 |
+
.gradio-container button:hover {
|
| 2053 |
+
border-color: rgba(212,168,75,0.35) !important;
|
| 2054 |
+
}
|
| 2055 |
+
|
| 2056 |
+
.gradio-container input,
|
| 2057 |
+
.gradio-container textarea,
|
| 2058 |
+
.gradio-container select {
|
| 2059 |
+
background: rgba(12,20,16,0.85) !important;
|
| 2060 |
+
border-color: var(--realm-glass-border) !important;
|
| 2061 |
+
line-height: var(--realm-line-height) !important;
|
| 2062 |
+
}
|
| 2063 |
+
|
| 2064 |
+
.book-container,
|
| 2065 |
+
.entity-detail-wrap {
|
| 2066 |
+
scroll-behavior: smooth;
|
| 2067 |
+
}
|
| 2068 |
+
|
| 2069 |
+
.skeleton-pulse {
|
| 2070 |
+
background: linear-gradient(90deg, rgba(42,74,58,0.3) 25%, rgba(58,90,68,0.45) 50%, rgba(42,74,58,0.3) 75%);
|
| 2071 |
+
background-size: 200% 100%;
|
| 2072 |
+
animation: skeletonShimmer 1.5s ease-in-out infinite;
|
| 2073 |
+
border-radius: 8px;
|
| 2074 |
+
min-height: 1.2rem;
|
| 2075 |
+
}
|
| 2076 |
+
|
| 2077 |
+
@keyframes skeletonShimmer {
|
| 2078 |
+
0% { background-position: 200% 0; }
|
| 2079 |
+
100% { background-position: -200% 0; }
|
| 2080 |
+
}
|
| 2081 |
+
|
| 2082 |
+
body, .gradio-container {
|
| 2083 |
+
line-height: var(--realm-line-height);
|
| 2084 |
+
letter-spacing: var(--realm-letter-spacing);
|
| 2085 |
+
}
|
| 2086 |
+
|
| 2087 |
+
.section-divider {
|
| 2088 |
+
height: 1px;
|
| 2089 |
+
margin: 2rem 0;
|
| 2090 |
+
background: linear-gradient(90deg, transparent, var(--realm-border-strong), transparent);
|
| 2091 |
+
}
|
world/presence.py
CHANGED
|
@@ -11,21 +11,47 @@ def init_presence_table() -> None:
|
|
| 11 |
CREATE TABLE IF NOT EXISTS presence (
|
| 12 |
session_id TEXT NOT NULL PRIMARY KEY,
|
| 13 |
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
| 14 |
-
location_id INTEGER
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
-
def heartbeat(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
"""Update last_seen for a session (upsert)."""
|
|
|
|
| 21 |
with db_session() as conn:
|
| 22 |
conn.execute("""
|
| 23 |
-
INSERT INTO presence (session_id, last_seen, location_id)
|
| 24 |
-
VALUES (?, datetime('now'), ?)
|
| 25 |
ON CONFLICT(session_id) DO UPDATE SET
|
| 26 |
last_seen = datetime('now'),
|
| 27 |
-
location_id = excluded.location_id
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def get_active_count(window_seconds: int = 120) -> int:
|
|
@@ -38,6 +64,42 @@ def get_active_count(window_seconds: int = 120) -> int:
|
|
| 38 |
return row["n"] if row else 0
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def get_recent_arrivals(since_seconds: int = 60) -> list[dict]:
|
| 42 |
"""Return entities created in the last N seconds by any session."""
|
| 43 |
with db_session() as conn:
|
|
|
|
| 11 |
CREATE TABLE IF NOT EXISTS presence (
|
| 12 |
session_id TEXT NOT NULL PRIMARY KEY,
|
| 13 |
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
| 14 |
+
location_id INTEGER,
|
| 15 |
+
x REAL NOT NULL DEFAULT 0,
|
| 16 |
+
z REAL NOT NULL DEFAULT 0,
|
| 17 |
+
yaw REAL NOT NULL DEFAULT 0,
|
| 18 |
+
display_name TEXT NOT NULL DEFAULT 'Visitor'
|
| 19 |
)
|
| 20 |
""")
|
| 21 |
+
for sql in [
|
| 22 |
+
"ALTER TABLE presence ADD COLUMN x REAL NOT NULL DEFAULT 0",
|
| 23 |
+
"ALTER TABLE presence ADD COLUMN z REAL NOT NULL DEFAULT 0",
|
| 24 |
+
"ALTER TABLE presence ADD COLUMN yaw REAL NOT NULL DEFAULT 0",
|
| 25 |
+
"ALTER TABLE presence ADD COLUMN display_name TEXT NOT NULL DEFAULT 'Visitor'",
|
| 26 |
+
]:
|
| 27 |
+
try:
|
| 28 |
+
conn.execute(sql)
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
|
| 32 |
|
| 33 |
+
def heartbeat(
|
| 34 |
+
session_id: str,
|
| 35 |
+
location_id: int | None = None,
|
| 36 |
+
x: float = 0,
|
| 37 |
+
z: float = 0,
|
| 38 |
+
yaw: float = 0,
|
| 39 |
+
display_name: str | None = None,
|
| 40 |
+
) -> None:
|
| 41 |
"""Update last_seen for a session (upsert)."""
|
| 42 |
+
name = (display_name or "Visitor").strip()[:32] or "Visitor"
|
| 43 |
with db_session() as conn:
|
| 44 |
conn.execute("""
|
| 45 |
+
INSERT INTO presence (session_id, last_seen, location_id, x, z, yaw, display_name)
|
| 46 |
+
VALUES (?, datetime('now'), ?, ?, ?, ?, ?)
|
| 47 |
ON CONFLICT(session_id) DO UPDATE SET
|
| 48 |
last_seen = datetime('now'),
|
| 49 |
+
location_id = excluded.location_id,
|
| 50 |
+
x = excluded.x,
|
| 51 |
+
z = excluded.z,
|
| 52 |
+
yaw = excluded.yaw,
|
| 53 |
+
display_name = excluded.display_name
|
| 54 |
+
""", (session_id, location_id, x, z, yaw, name))
|
| 55 |
|
| 56 |
|
| 57 |
def get_active_count(window_seconds: int = 120) -> int:
|
|
|
|
| 64 |
return row["n"] if row else 0
|
| 65 |
|
| 66 |
|
| 67 |
+
def get_active_visitors(
|
| 68 |
+
location_id: int,
|
| 69 |
+
exclude_session_id: str | None = None,
|
| 70 |
+
window_seconds: int = 20,
|
| 71 |
+
) -> list[dict]:
|
| 72 |
+
"""Return live visitors currently walking inside a location."""
|
| 73 |
+
params: list = [location_id, f"-{window_seconds}"]
|
| 74 |
+
query = """
|
| 75 |
+
SELECT session_id, display_name, x, z, yaw, last_seen
|
| 76 |
+
FROM presence
|
| 77 |
+
WHERE location_id = ?
|
| 78 |
+
AND last_seen >= datetime('now', ? || ' seconds')
|
| 79 |
+
"""
|
| 80 |
+
if exclude_session_id:
|
| 81 |
+
query += " AND session_id != ?"
|
| 82 |
+
params.append(exclude_session_id)
|
| 83 |
+
query += " ORDER BY last_seen DESC LIMIT 16"
|
| 84 |
+
|
| 85 |
+
with db_session() as conn:
|
| 86 |
+
rows = conn.execute(query, params).fetchall()
|
| 87 |
+
return [dict(r) for r in rows]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def cleanup_stale_sessions(max_age_seconds: int = 300) -> int:
|
| 91 |
+
"""Remove presence rows older than max_age_seconds."""
|
| 92 |
+
with db_session() as conn:
|
| 93 |
+
cur = conn.execute(
|
| 94 |
+
"""
|
| 95 |
+
DELETE FROM presence
|
| 96 |
+
WHERE last_seen < datetime('now', ? || ' seconds')
|
| 97 |
+
""",
|
| 98 |
+
(f"-{max_age_seconds}",),
|
| 99 |
+
)
|
| 100 |
+
return cur.rowcount
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def get_recent_arrivals(since_seconds: int = 60) -> list[dict]:
|
| 104 |
"""Return entities created in the last N seconds by any session."""
|
| 105 |
with db_session() as conn:
|