kavyabhand commited on
Commit
69bb31b
·
verified ·
1 Parent(s): 3155ab5

Deploy Aether Garden application

Browse files
Files changed (7) hide show
  1. .pytest_cache/v/cache/lastfailed +3 -0
  2. app.py +102 -16
  3. assets/diorama.html +279 -16
  4. ui/hero.py +11 -1
  5. ui/map.py +9 -7
  6. ui/styles.css +435 -4
  7. 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 heartbeat as presence_heartbeat, get_active_count, get_recent_arrivals
 
 
 
 
 
 
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, key: code.replace('Key', '').toLowerCase(),
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 = window._aetherActiveFrame
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%; bottom: 18%; transform: translateX(-50%);
14
- z-index: 25; display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
15
- padding: 0.55rem 1.1rem; border-radius: 999px; cursor: pointer; text-align: center;
16
- background: rgba(8,12,10,0.82); border: 1px solid rgba(212,168,75,0.38);
17
  color: #f0d9a0; outline: none; pointer-events: auto;
18
- box-shadow: 0 8px 28px rgba(0,0,0,0.45);
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: translateX(-50%) scale(1); }
29
- 50% { opacity: 1; transform: translateX(-50%) scale(1.02); }
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 walk</p>
237
- <span>use WASD keys or the pad at bottom-left · drag to look</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 &nbsp;|&nbsp; Drag · Look &nbsp;|&nbsp; E · Talk &nbsp;|&nbsp; 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)) enterFPS();
 
 
 
 
 
 
 
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('/gradio_api/run/chat_with_soul', {
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('/gradio_api/run/assign_quest',{
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
- if (e.data.action === 'down') pressKey(e.data.code);
1626
- else releaseKey(e.data.code);
 
 
 
 
 
 
 
 
 
 
 
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 &nbsp;|&nbsp; Drag · Look &nbsp;|&nbsp; E · Talk &nbsp;|&nbsp; 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
- <img src="{assets.banner_url()}" alt="Aether Garden — The World That Remembers" class="hero-image"/>
 
 
 
 
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" stroke="rgba(212,168,75,0.12)" '
112
- f'stroke-width="2" stroke-dasharray="6,8" stroke-linecap="round"/>'
 
 
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="40" fill="{loc["glow_color"]}" opacity="0.08" '
135
- f'filter="url(#{filter_id})"/>'
136
  )
137
  svg_parts.append(
138
- f'<circle r="34" fill="{loc["glow_color"]}" opacity="0.18" '
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: 1px solid var(--realm-border-strong);
1274
- border-radius: 14px;
1275
  overflow: hidden;
1276
  background: var(--realm-bg-secondary);
1277
- box-shadow: 0 14px 48px rgba(0,0,0,0.55);
 
 
 
 
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(session_id: str, location_id: int | None = None) -> None:
 
 
 
 
 
 
 
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
- """, (session_id, location_id))
 
 
 
 
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: