feat(tasks): unify Workspace task board with Hermes Kanban backend (#311) (#348)

* wip(hermesworld): viral sprint checkpoint - landing rebuild + character pipeline scaffold

- standalone /hermes-world and /world routes bypass workspace shell
- root overlay leaks gated for landing + game surfaces
- character pipeline scaffolding (player/npc/glb-body components)
- canonical asset path public/assets/hermesworld/characters/
- docs: landing-page-spec, graphics-usability-plan, agora-believable-checklist, master-roadmap
- handoff at memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md

Local-only checkpoint. Not for upstream yet.

* feat(playground): persistent admin mode toggle with shield button

- Admin mode now persists via localStorage (key: hermes-playground-admin)
- Shield icon button in HUD (right rail, below focus toggle, md+)
- Click toggles admin panel and saves preference
- ?admin=1 URL param still works as override
- gitignore swarm worker scratch dirs

Mission: memory/swarm/missions/2026-05-05-pr-triage.md (5 swarm lanes dispatched on 19 open PRs, no-merge contract)

* feat(landing): add Play Now CTAs to HermesWorld landing

- Hero: Play Now (primary, gold), View on GitHub (demoted), Read Roadmap
- Header nav: Play badge (highlighted gold)
- Final CTA: Play Now (primary), GitHub + Roadmap (secondary)

All Play buttons go to /playground which mounts the title screen
(username + character customizer + Enter). Sets up the public-URL
deploy: hermes-world.ai → / serves landing → click Play → /playground.

* fix(tasks): use shared kanban backend

---------

Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
Eric
2026-05-05 16:46:24 -04:00
committed by GitHub
parent 491a152b33
commit 4f177f9b8d
42 changed files with 3480 additions and 231 deletions

1
.gitignore vendored
View File

@@ -145,3 +145,4 @@ skills-bundle/
# Local agent artifacts (audit dumps, temporary playwright scripts)
.hermes/
.env.bak-*
pr-triage-20260505-*/

View File

@@ -0,0 +1,48 @@
# Agora Believable Checklist
Status: active first implementation slice
Owner: Eric / Aurora
## Objective
Turn Agora into the first zone that feels like a real game scene instead of a promising prototype.
## Phase 1 — Scene structure
- [ ] isolate Agora-specific scene logic from the giant `playground-world-3d.tsx`
- [ ] identify current player model/render path
- [ ] identify current NPC render path
- [ ] define where `PlayerCharacter` and `NpcCharacter` will mount
## Phase 2 — Characters
- [x] scaffold character archetype config
- [x] scaffold `PlayerCharacter` component boundary
- [x] scaffold `NpcCharacter` component boundary
- [ ] replace one player stand-in with `PlayerCharacter`
- [ ] replace one guard/oracle NPC stand-in with `NpcCharacter`
- [ ] wire label + selection behavior to new character components
## Phase 3 — Agora composition
- [ ] strengthen central monument silhouette
- [ ] improve radial stone paving / circular plaza readability
- [ ] cluster benches / stalls / torches more intentionally
- [ ] place NPCs in authored conversational groups
- [ ] remove any obviously toy-like placeholder blocking
## Phase 4 — Lighting and atmosphere
- [ ] improve key light direction
- [ ] add stronger warm firelight pools
- [ ] add controlled fog / distance atmosphere
- [ ] tune bloom/post so the scene feels rich, not blurry
## Phase 5 — HUD and readability
- [ ] tighten objective panel
- [ ] reduce minimap visual noise
- [ ] improve NPC label readability
- [ ] improve interaction prompts
- [ ] make bottom action bar read more like game UI, less prototype
## Success criteria
- [ ] player character looks like a believable human silhouette
- [ ] at least 3 NPCs feel believable and differentiated
- [ ] Agora screenshot looks postable on X without apology
- [ ] objective flow is obvious at first glance
- [ ] no leftover workspace-local chrome leaks into the public/game-facing surface

View File

@@ -0,0 +1,309 @@
# HermesWorld Graphics + Usability Development Plan
Status: start now
Owner: Eric / Aurora
Stack: Hermes Workspace + React + Three.js / React Three Fiber
## Goal
Upgrade HermesWorld from "promising web demo" to "serious playable world" with:
- stronger visual identity
- much better usability
- more believable characters
- better screenshot / clip quality
- a clean path that stays web-native
## Core decision
Do **not** switch engines now.
Stay on:
- **Three.js**
- **React Three Fiber**
- current Hermes Workspace integration
Reason:
- web shareability matters
- X traffic matters
- embedding/dashboard mode matters
- current blockers are art direction, assets, rendering, and UX, not engine choice
## Hard truth on "real-looking characters"
### What is realistic right now
We can get to:
- **stylized-real / semi-real / premium MMO-lite**
- strong silhouettes
- better faces/hair/clothes
- better animation
- more human proportions
- better materials and lighting
### What is not realistic right now
Not this sprint:
- AAA Unreal photoreal humans
- fully custom hero character system from scratch
- hundreds of unique high-end characters
## Recommended character direction
Target look:
- **semi-real fantasy RPG characters**
- more grounded than Roblox / low-poly toy figures
- less uncanny than rushed photoreal
- readable at gameplay distance
- works in browser performance budgets
## Character pipeline to start now
### Best first path
Use a proven character source instead of inventing characters from primitives.
Recommended order:
1. **Ready Player Me** or equivalent avatar source for fast believable human bases
2. **Mixamo** for animation clips
3. export to **GLB**
4. optimize materials / texture sizes for browser
5. adapt wardrobe/colors to HermesWorld visual language
### Why this path
- gets believable humans fast
- works with browser GLB pipeline
- animation is solved sooner
- lets us focus on world + UX instead of making characters from zero
### First character set we need
- player base male
- player base female
- scholar / oracle NPC
- blacksmith / forge NPC
- guard / knight NPC
- merchant / villager NPC
Do **not** start with huge variety.
Get 4-6 great archetypes first.
## Animation pipeline
### First animation pack
Need:
- idle
- walk
- run
- talk / gesture
- inspect / use
- celebrate / emote
- sit or kneel if easy
### Character behavior goals
- no stiff statue NPCs
- idle should feel alive
- movement should read clearly from distance
- talking should feel intentional even before lip sync
## Environment upgrade priorities
## 1. Landmark pass
Every zone needs strong silhouette.
Immediate targets:
- Agora central monument / obelisk / sigil altar
- Oracle tower / ring structure
- Forge furnace / chimney / heat source
- Grove memory tree / crystal tree / archive roots
- Arena ring / banners / gates
- Training Grounds camp / gate / tutorial shrine
## 2. Ground + path pass
Fix the current "objects on a plane" feeling.
Need:
- stronger stone paths
- path borders
- elevation changes
- stairs / slopes / platform edges
- clustered vegetation instead of random scatter
## 3. Prop clustering
Move from generic scattered props to authored compositions.
Need:
- market stalls that actually compose into scenes
- bench / torch / crate / barrel clusters
- decorative banners and signposts
- repeating prop kits per zone
## 4. Lighting / atmosphere
Highest ROI visual upgrade.
Need:
- stronger key light direction
- fog / distance atmosphere
- warm firelight pools
- cooler shadow balance
- subtle bloom / post processing
- skybox that supports mythic mood
## Usability upgrade priorities
## 1. Readability of objective flow
The user should always know:
- where they are
- what to do next
- what is interactable
- what their agents are doing
### Immediate changes
- clearer quest objective widget
- stronger waypoint / marker language
- better hover/interact outlines
- more distinct NPC labels
## 2. HUD cleanup
Current HUD should feel more like a game and less like mixed prototype layers.
Need:
- one consistent HUD material language
- clearer bottom action bar
- tighter minimap frame
- cleaner player stats card
- less visual noise from debug/admin leftovers
## 3. Chat / social UX
Chat should not dominate the scene.
Need:
- smaller, cleaner chat panel
- better contrast and message hierarchy
- easier collapse/minimize behavior
- less overlap with gameplay space
## 4. Input / interaction cues
Need immediate affordances:
- interact prompt treatment
- click target confidence
- path-to-target feedback
- clearer selected NPC / object state
## Performance plan
This all has to stay browser-safe.
### Rules
- prefer **instancing** for repeated props
- reduce unique materials
- compress textures
- cap texture resolution aggressively
- use LOD where needed
- keep postprocessing restrained
- test draw calls after each art pass
### Budget mindset
For each major scene ask:
- how many characters on screen?
- how many unique materials?
- how many dynamic lights?
- how many transparent effects?
## Development tracks
## Track A — Characters
Deliver believable people fast.
### Start now
- choose character source
- pick 4-6 NPC archetypes
- pick 1 player archetype
- get GLB import path working cleanly
- wire idle + walk + talk test scene
## Track B — Agora visual remake
Use Agora as the first gold-standard zone.
### Start now
- rebuild central plaza composition
- improve stone ground / path circles
- add stronger firelight and monument detail
- replace placeholder NPC bodies with first believable characters
## Track C — HUD / usability pass
### Start now
- objective widget redesign
- minimap cleanup
- stats card cleanup
- interaction marker cleanup
- remove any workspace-local/debug carryover from game-facing UI
## Track D — Asset pipeline
### Start now
- standardize GLB imports
- define texture size limits
- define naming conventions
- define animation clip naming
- define NPC archetype list
## Immediate first sprint
### Sprint 1: "Agora believable"
Make only the Agora look and feel dramatically better.
Ship these first:
1. one upgraded player character
2. three upgraded NPC archetypes
3. better idle/walk/talk animations
4. rebuilt central plaza composition
5. better firelight/fog/post
6. cleaned HUD around objective + minimap + bottom bar
If Agora feels real, the rest of the world becomes believable.
## Exact first implementation steps
### Step 1
Replace current primitive character stand-ins with imported GLB humanoids.
### Step 2
Add animation controller for:
- idle
- walk
- gesture/talk
### Step 3
Rebuild Agora center using:
- stronger monument
- radial paving
- authored prop clusters
- meaningful NPC placement
### Step 4
Polish camera and UI for the new scene.
### Step 5
Capture screenshots/clips and tune from what looks weak.
## Success criteria
We know this is working when:
- a screenshot reads like a real game scene, not a prototype
- characters look like people, not placeholders
- the objective and interactions are obvious
- the page is clip-worthy on X
- the browser still runs smoothly
## My recommendation
Start **now** with:
1. character pipeline
2. Agora remake
3. HUD cleanup
That is the shortest path to the result you actually want.
## Immediate next task
Implement the first "Agora believable" pass:
- import better humanoid characters
- wire idle/walk/talk clips
- rebuild the central plaza composition around those characters
- tune lighting/fog/post
- clean HUD overlap

View File

@@ -0,0 +1,202 @@
# HermesWorld Master Roadmap
Status: active build sprint
Owner: Eric / Aurora
Repo: `outsourc-e/hermes-workspace`
Scope: HermesWorld inside Hermes Workspace, dashboard/plugin embedded first, standalone later
## Product thesis
HermesWorld is no longer a novelty route. It is the playable layer for Hermes Workspace: a persistent world where humans and agents can move, talk, complete missions, unlock progression, and eventually keep working while the human is away.
Keep HermesWorld in `hermes-workspace` for now. The tight coupling to workspace state, sessions, agents, plugins, quests, and dashboard embeds is the feature. A standalone destination can ship as a route/deploy target later without splitting source.
## North star
HermesWorld should become:
- **playable by humans**: polished RPG/MMO onboarding, chat, quests, inventory, progression, multiplayer presence
- **operable by agents**: deterministic action verbs, quests, travel, equipment, combat/evals, offline progression
- **persistent**: durable player profiles, world events, analytics, session handoff, reconnect truth
- **dashboard-embeddable**: first-class private/admin and public embed surfaces
- **standalone shareable**: public landing/deep links when the product surface is ready
## This week, shipping track
### 1. Admin UX cleanup
Ship a dashboard/plugin-only admin surface for Eric. Do not put admin controls on the public loading page.
Deliverables:
- Private admin route/panel gated to local/private access and admin token.
- Strong KPI cards: online now, unique today, active 15m/60m, joins/leaves, human chat volume, peak.
- Recent players with world, last seen, last chat, join/chat counts.
- Recent events with clear type styling and world labels.
- Human vs NPC truth called out explicitly. Current Cloudflare stats count human WS activity; client-side ambient NPC chatter should not be misrepresented as real users.
- Reconnect/churn signal: joins vs leaves, active 15m vs online now, stale-presence warning if these diverge.
Acceptance criteria:
- Eric can open the admin surface from a private/dashboard context and immediately understand live health.
- Stats do not pretend bots are humans.
- Recent player/event lists are scannable at a glance.
### 2. Visual polish pass
Execute `docs/hermesworld/visual-upgrade-spec.md` aggressively. TinySkies is the atmosphere/environment reference, not the code architecture.
Deliverables:
- Zone-specific camera/lighting/fog/sky tuning.
- Landmark emphasis in every zone.
- More readable paths and objective direction.
- Denser low-poly props without clutter.
- HUD/chat/nameplates move from dev-tool glass to premium game UI.
- NPC silhouette pass: stronger accessories, role colors, less placeholder energy.
Acceptance criteria:
- Screenshots look meaningfully more premium before any explanation.
- Each zone reads from one frame.
- The player sees where to go without reading a paragraph.
### 3. Chat / NPC cleanup
Deliverables:
- Reduce ambient NPC message flooding.
- Separate human chat from ambient/NPC chatter in the UI.
- Label local fallback/bot presence honestly.
- Keep multiplayer chat useful for humans, not drowned by scripted lines.
Acceptance criteria:
- Human messages are visually dominant.
- NPC flavor adds life but does not spam.
- Admin metrics and chat labels use truthful language.
### 4. Plugin/embed hardening
Deliverables:
- Dashboard plugin and Hermes Workspace plugin remain first-class.
- `embed=1` mode stays clean and chrome-free where appropriate.
- Plugin install flow remains git-based and obvious.
- Admin stays private/plugin-only.
Acceptance criteria:
- Public users see the world, not admin machinery.
- Dashboard users can embed/control without layout weirdness.
## Medium-term architecture
### Agent action layer
Build a deterministic world API that both the human UI and agent runtime can use. Avoid UI hacks like clicking DOM nodes from an agent.
Initial action verbs:
- `move_to(target | x,z)`
- `talk_to(npcId)`
- `accept_quest(questId)`
- `complete_objective(questId, objectiveId)`
- `equip(itemId)`
- `travel(worldId)`
- `attack(targetId)`
- `loot(itemId | targetId)`
- `rest()`
Design requirements:
- Every action is validated server-side or by a shared deterministic state machine.
- Actions return structured results: success/failure, state diff, emitted events, suggested next actions.
- Human UI should call the same action layer where possible.
- Agents should be able to plan from world state, not screenshots.
### Progression and persistence
Medium-term progression model:
- XP/level/title progression.
- Quest chains per zone.
- Inventory/equipment affecting verbs.
- Unlockable travel gates.
- World event log.
- Durable profile storage beyond localStorage.
Persistence stages:
1. localStorage profile, current
2. dashboard/plugin-backed profile sync
3. account/session-backed cloud profile
4. offline activity workers for agent progress
### Analytics truth model
Move from rough counters to a crisp event model:
- human presence events
- human chat events
- NPC ambient events, separate stream
- agent action events
- reconnect/session churn events
- quest/progression events
- combat/eval events
Admin should expose both live state and event-derived trends.
## Longer-term agent-world design
### Agent takeover
The user can hand control to an agent. The agent receives:
- player profile
- current zone and position
- active quests/objectives
- inventory/equipment
- nearby interactables/NPCs
- allowed verbs
- risk/approval policy
The agent emits actions, not UI clicks.
### Offline progression
When the user sleeps, their agent can keep progressing inside bounded rules:
- user-configured goal, for example `level to 5`, `finish Training Grounds`, `farm Forge shards`
- max time/resource budget
- safe action allowlist
- summarized event log on return
- no irreversible marketplace/public actions without approval
### Agent-to-agent combat / battle loop
Future battle loop should reuse eval concepts:
- arena match = structured task/eval
- agents choose abilities/tools/models
- scoring combines objective result, speed, cost, style/quality
- loot/rewards map back to workspace abilities or cosmetics
This makes HermesWorld a visible layer for agent benchmarking and learning, not just a toy combat system.
## Implementation order
1. Admin panel cleanup and truthful metrics labels.
2. Chat/NPC flood reduction and separation.
3. Visual atmosphere constants and HUD material pass.
4. Landmark/path readability pass per zone.
5. Shared action-layer spec in code (`lib/playground-actions.ts`) with types first.
6. Wire one real action path through the type layer, likely `travel` or `equip`.
7. Standalone landing surface once the embedded product feels premium.
## Open risks
- Cloudflare relay analytics are useful but still approximate if tabs hibernate/reconnect aggressively.
- Local bot/NPC activity can make the world feel alive, but must never pollute human metrics.
- Visual richness must stay lightweight, no giant asset pipeline yet.
- Agent-playable systems need deterministic state transitions before autonomy, otherwise agents will become brittle screen-clickers.

9
memory/2026-05-04.md Normal file
View File

@@ -0,0 +1,9 @@
## Late-night HermesWorld product sprint kickoff
- Eric started a fresh GPT-5.5 HermesWorld build sprint and reframed HermesWorld as a real product, not just a demo. Keep it inside `hermes-workspace`; dashboard/plugin embed remains first-class; standalone/shareable can come later without splitting source.
- Product direction locked: playable by humans, operable by agents, persistent, dashboard-embeddable, eventually standalone. Admin should be private/dashboard-only, not on the loading page.
- Created `docs/hermesworld/master-roadmap.md` organizing this week's shipping work, medium-term architecture, and long-term agent-world design.
- First local implementation slice: upgraded admin panel UI/truth labels, separated human vs ambient NPC chat in the chat UI, throttled ambient NPC chatter, tuned zone atmosphere/lighting/fog constants, and added `src/screens/playground/lib/playground-actions.ts` as the deterministic action protocol scaffold for future agent play.
- `pnpm build` passed after the slice. Existing local dirty files from prior work remain mixed with new sprint files; do not assume only this sprint changed the worktree.
- Continued HermesWorld local-only polish: added readable glowing route ribbons in Training Grounds, upgraded Forge Gate landmark with stronger portal/plinth/light treatment, and refined HUD materials toward premium game panels. `pnpm build` passed again.

7
memory/2026-05-05.md Normal file
View File

@@ -0,0 +1,7 @@
# 2026-05-05
- HermesWorld local-only sprint continued during trending/public-momentum moment. User explicitly wants everything local until graphics/polish are strong.
- Added additional hero landmark visuals locally in `/Users/aurora/hermes-workspace`: Forge super-furnace centerpiece, Grove memory tree, Oracle rotating ring tower, Arena inner medallion/spotlight, plus prior Training Grounds route ribbons/Forge Gate/HUD polish. `pnpm build` passed after the landmark pass.
- Eric bought `hermes-world.ai` and wants a landing page shipped ASAP. Built local HermesWorld landing routes `/hermes-world` and `/world` with a screenshot-2-inspired low-poly diorama hero, zone cards, launch CTAs, and domain-ready copy. Build passed. Next deploy decision needed: map `hermes-world.ai` root to this landing route and `/playground` to the playable app.
- Eric said landing page was too basic and needs Hermes Workspace design language, video/preview inside hero, stronger viral vibe, and asked whether to move off web. Reworked landing UI locally: dark Hermes gold/cyan design language, live `/playground?embed=1` iframe inside the hero and trailer section, clearer explanation that graphics updates are inside the game build, Sigils token-as-game-lore section. Build passed. Recommendation given: web first for viral link distribution, native/off-web later after shareable demo loop is proven.
- Wrote full resume handoff for the next `/new` session at `memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md`, including current local state, landing/game work completed, latest Downloads refs (`landing.png`, `logos.png`, `world.png`), priority order, and the grounded recommendation to keep web first while exploring crazier future packaging later.

View File

@@ -0,0 +1,214 @@
# Iteration 008 — 3002 loading-loop handoff for Opus
Date: 2026-05-04 01:13 EDT
Repo: `/Users/aurora/hermes-workspace`
Current branch: `main`
Current main head at handoff: `cb2ecec5f`
---
## Situation
HermesWorld has been merged into local `main` and pushed to `origin/main`.
### Merge / ship status
- Feature branch: `feat/agent-view-port-from-controlsuite`
- Merged local `main` into the feature branch first, resolved conflicts, build passed.
- Merged feature branch into local `main`.
- Pushed `main` to GitHub:
- remote push range: `8f31e113b..cb2ecec5f`
### Product state
- HermesWorld is live in the repo and shipped to GitHub main.
- 3005 preview/worktree behaved correctly.
- 3002 (local main workspace dev server) has had a persistent **loading loop** for Eric, even after merges and refresh attempts.
---
## What was already done
### HermesWorld changes already merged
Recent relevant commits on the feature branch before merge included:
- `8312ba810` — rebrand Playground nav item to HermesWorld with gold NEW badge
- `215bdf8a4` — featured HermesWorld nav slot + sidebar handling
- `0d4f6fec7` — keep left sidebar visible on HermesWorld
- `58d8c8f11` — finish HermesWorld branding pass
- `cb2ecec5f` — merge local main into feature branch
### Merge conflict policy used
During merge-from-main into feature branch, conflicts were resolved by:
- taking **main** as source of truth for dashboard/agent-view/chat layout
- preserving **HermesWorld branding + multiplayer env config** where relevant
Conflict files that were resolved during that merge:
- `.env.example`
- `src/components/agent-view/agent-view-panel.tsx`
- `src/screens/chat/chat-screen.tsx`
- `src/screens/dashboard/dashboard-screen.tsx`
---
## 3002 loading-loop investigation already attempted
### 1. Verified local server / backend modes
Found a real mismatch initially:
- `3002` was running against **portable backend** `8645`
- `3005` was running against **enhanced backend** `8642`
This was likely wrong for comparing HermesWorld behavior.
### 2. Changed local main `.env`
Edited:
- `/Users/aurora/hermes-workspace/.env`
Changed:
- `HERMES_API_URL=http://127.0.0.1:8645``http://127.0.0.1:8642`
- `CLAUDE_API_URL=http://127.0.0.1:8645``http://127.0.0.1:8642`
### 3. Restarted 3002 repeatedly
3002 was restarted multiple times after:
- backend URL switch
- Vite cache wipe (`node_modules/.vite` removed)
- clean `pnpm dev` restarts
### 4. Verified health endpoints
After the restart, 3002 reported healthy:
- `/api/connection-status``{"ok":true,"mode":"enhanced","backend":"http://127.0.0.1:8642"}`
- `/api/auth-check``{"authenticated":true,"authRequired":false}`
- `/api/gateway-status` → valid JSON on both 3002 and 3005
So by the end, backend/auth/gateway health looked good.
### 5. Notable false lead
At one point `/api/gateway-capabilities` returned app HTML instead of JSON.
That looked suspicious, but later investigation showed the real route in current main is `/api/gateway-status`, and that endpoint was healthy.
### 6. Shell fallback patch added
To guard against a wedged startup overlay, a fallback check was added to:
- `src/components/workspace-shell.tsx`
What it does:
- imports `fetchClaudeAuthStatus`
- adds a `useEffect` that, if `connectionVerified` is still false, tries:
1. `/api/auth-check` via `fetchClaudeAuthStatus(3000)`
2. then `/api/connection-status`
- if either is healthy, it calls:
- `setAuthStatus({ authenticated: true, authRequired: false })`
- `setConnectionVerified(true)`
Intent:
- if the `ConnectionStartupScreen` itself gets stuck, the shell should still unlock.
3002 was restarted after this patch too.
---
## Current mystery
Despite:
- healthy auth endpoint
- healthy connection-status endpoint
- healthy gateway-status endpoint
- enhanced backend on 8642
- shell fallback patch
Eric still reports **the same loading loop on 3002**.
That suggests one of these is true:
1. it is **not** the startup/auth overlay at all,
2. there is a separate UI-level infinite loading state on main,
3. browser/client state is stale in a more specific way,
4. a route/layout shell interaction on main differs from the 3005 preview in some subtle way,
5. HMR / generated route artifacts / dev-server state on 3002 is still inconsistent.
---
## Strong hypotheses for Opus to test
### Hypothesis A — wrong overlay/component
It may not be `ConnectionStartupScreen` at all.
Ask Eric for a screenshot immediately if needed and identify the exact visible component.
### Hypothesis B — main vs worktree route/layout drift
Compare these files between 3002 main and the working 3005 worktree preview:
- `src/components/workspace-shell.tsx`
- `src/components/connection-startup-screen.tsx`
- `src/routes/__root.tsx`
- `src/routes/playground.tsx`
- `src/screens/playground/playground-screen.tsx`
- `src/screens/chat/components/chat-sidebar.tsx`
### Hypothesis C — generated route tree / Vite dev weirdness
The dev logs repeatedly showed warnings like:
- `send-stream-live-tools.ts does not export a Route`
- `routeTree.gen.ts was modified by another process during processing`
This may be harmless, but it smells like route generation churn. Worth checking whether 3002 is serving a bad in-memory state.
### Hypothesis D — browser-only state
Could be localStorage/sessionStorage / persisted Zustand state / service worker / stale route state. We suspected this already, but it remains plausible.
---
## Suggested next steps for Opus
1. **Get a screenshot first** if possible.
- Identify whether the loop is:
- startup/auth overlay,
- splash,
- route shell,
- playground transition loading screen,
- some other loader.
2. **Compare 3002 vs 3005 route/layout state**.
- Especially `workspace-shell.tsx`, `__root.tsx`, and startup/auth flow.
3. **Check whether the new fallback patch in `workspace-shell.tsx` actually compiled into 3002**.
- Search built output or runtime behavior if needed.
4. **Inspect client state assumptions**.
- localStorage keys
- sessionStorage keys
- onboarding completion flags
- any persisted auth/loading flags
5. **Check if HermesWorld transition loader itself is what's looping**.
- Search for `transitioning`, `TransitionLoadingScreen`, and route enter logic in `playground-screen.tsx`.
6. **If necessary, temporarily add ultra-obvious debug text** to the suspected overlay component so Eric can tell which one is on screen.
---
## Useful commands / facts from this session
### 3002 health
- `curl -s http://localhost:3002/api/connection-status`
- `curl -s http://localhost:3002/api/auth-check`
- `curl -s http://localhost:3002/api/gateway-status`
### 3005 comparison
- `curl -s http://localhost:3005/api/connection-status`
- `curl -s http://localhost:3005/api/gateway-status`
### Observed good state on 3002 near the end
- `mode: enhanced`
- `backend: http://127.0.0.1:8642`
- `auth-check: authenticated true`
- `gateway-status: valid JSON`
### Dev log location
- `/tmp/hermes3002.log`
Recurring log noise:
- `send-stream-live-tools.ts does not export a Route`
- `routeTree.gen.ts was modified by another process during processing`
---
## Bottom line
This is no longer a simple backend/auth failure.
The obvious transport and auth issues were fixed, yet Eric still sees the loop.
Opus should assume:
- the loop is probably a **specific UI component/state machine**, or
- 3002 dev-mode has a **route/layout/state divergence** from 3005 that needs targeted comparison.

View File

@@ -0,0 +1,84 @@
# Iteration 009 — 3002 loading-loop ROOT CAUSE FOUND
Date: 2026-05-04 01:27 EDT
Repo: `/Users/aurora/hermes-workspace`
Branch: `main` (head: `72dd321e8`)
---
## Root cause
**Three concurrent `vite dev` processes were running against the same `hermes-workspace` source tree.**
Process inventory at start of session:
- pid 40934 — vite dev on **:3001** (running 36+ min, cwd `/Users/aurora/hermes-workspace`)
- pid 8826 — vite dev on **:3003** (running 12+ hours, same cwd)
- pid 8866 — vite dev (zombie pair to 8826, same cwd)
- pid 50028 — vite dev on **:3002** (the one we cared about, same cwd)
- pid 71649 — vite dev on **:3005** (different cwd: `/Users/aurora/.worktrees/hermes-playground-local`) — **fine**
- pid 11433 — vite dev on **:3006** (worktree, fine)
All three duplicate `hermes-workspace` vite servers were running **TanStack Router's
file-based route generator** simultaneously. They each wrote `src/routeTree.gen.ts`,
detected another writer's mtime change, and re-ran the generator. This caused:
1. Perpetual `routeTree.gen.ts was modified by another process during processing` warnings
2. Constant HMR reload signals fired at every browser client
3. The browser would re-execute the splash + startup chain on every reload, never settling
That is the loading loop Eric was seeing. The auth/connection layer was never the
problem — the page was being yanked out from under itself by HMR every few hundred ms.
## Evidence
- 34,039 instances of "modified by another process" in `/tmp/hermes3002.log`
before kill (file size ~10 MB).
- After killing pids 40934, 8826, 8866 and starting a single fresh vite on 3002:
- `routeTree.gen.ts` mtime is **stable** across multiple checks
- log file size stops growing
- all health endpoints clean (`/api/connection-status`, `/api/auth-check`,
`/api/gateway-status`)
## Fix applied
1. Killed the three duplicate hermes-workspace vite processes.
2. Started a single fresh `pnpm dev` on 3002.
3. Renamed `src/routes/api/send-stream-live-tools.ts`
`src/routes/api/-send-stream-live-tools.ts` so TanStack's ignore prefix skips
it cleanly. (Updated `send-stream.ts` and the test file's imports.) This silences
the secondary "does not export a Route" warning. **It is not the root cause**,
but the noise was a red herring during the prior investigation.
Committed: `72dd321e8` on `main`.
## How to recognize this in the future
If 3002 loops, **before** touching auth/startup code, check:
```bash
ps aux | grep -i vite | grep -v grep
lsof -nP -iTCP:3002 -iTCP:3001 -iTCP:3003 -iTCP:3004 -iTCP:3005 -sTCP:LISTEN
```
If more than one vite is bound to the same source tree (`/Users/aurora/hermes-workspace`),
that is the bug. Worktree vites at different cwds are fine.
A single command that detects it:
```bash
ps aux | grep -E "vite.*hermes-workspace/node_modules" | grep -v grep | grep -v worktree | wc -l
```
Should return `1` (or `0` if dev not running). Anything more means duplicates.
## Status of prior iteration's shell-fallback patch
The fallback `useEffect` in `src/components/workspace-shell.tsx` (added in
iteration 008) is still in place and harmless. It was not the fix but it does
not need to be reverted.
## Bottom line
3002 loading loop was infrastructural, not in app code:
multiple vite servers fighting over `routeTree.gen.ts`. Killed the duplicates,
loop is gone.

View File

@@ -0,0 +1,246 @@
# HermesWorld Viral Sprint Handoff
Date: 2026-05-05 14:22 EDT
Owner: Eric / Aurora
Repo: `/Users/aurora/hermes-workspace`
Status: local-only, uncommitted, handoff for next Opus session
## What landed this session
### 1. HermesWorld landing was rebuilt into a standalone public surface
Routes in repo:
- `/hermes-world`
- `/world`
Key file:
- `src/screens/playground/hermes-world-landing.tsx`
Landing now has:
- world-first public launch structure
- hero, capability strip, launch-drop section, zones, agents, sigils, final CTA, footer
- GitHub / roadmap / feature-list links instead of local-only `/playground` public CTAs
- stronger HermesWorld-specific copy
- launch-day messaging like:
- “Dropping HermesWorld today.”
- landing page / roadmap / feature list / graphics sprint
### 2. HermesWorld landing now bypasses workspace chrome
The biggest source of confusion was root/app-shell leakage.
Fixed or partially fixed:
- `/hermes-world` and `/world` now bypass the **WorkspaceShell** and scroll like normal pages
- local-only workspace chrome removed from the landing route
- app splash skipped on HermesWorld landing routes
- route is intended to be document-scrollable, not app-pane scrollable
Touched:
- `src/components/workspace-shell.tsx`
- `src/routes/__root.tsx`
### 3. Multiple root-level overlays were traced and removed/gated
Found and addressed these root-level leak sources:
- `UsageMeter`
- `SearchModal`
- `KeyboardShortcutsModal`
- `UpdateCenterNotifier`
- `MobilePromptTrigger`
- `OnboardingTour`
Important note:
- Some of these were gated only for landing routes.
- Later, game surfaces like `/playground` also needed special treatment.
### 4. Bottom-right usage/session pill leak on HermesWorld/game surfaces was addressed
This `SESSION | IN | OUT | CTX | COST` widget was another workspace overlay leak.
Action taken:
- hid the global usage/session pill for **game surfaces**, not just the landing page
Touched:
- `src/routes/__root.tsx`
### 5. Sidebar overlap bugs in `/playground` were addressed
On 3002 with the left sidebar open, gameplay UI elements overlapped badly.
Fix direction implemented:
- made several game UI pieces sidebar-aware so they shift when the left sidebar is open
Touched:
- `src/screens/playground/components/playground-hud.tsx`
- `src/screens/playground/components/playground-chat.tsx`
- `src/screens/playground/playground-screen.tsx`
The intended fix was:
- use `useWorkspaceStore((s) => s.sidebarCollapsed)`
- shift HUD/chat/chips to `320px` left offset when sidebar is open
- keep `min(120px, 9vw)` behavior when collapsed
### 6. HermesWorld landing hero was shifted back toward the old glowing-orb vibe
Eric preferred the older orb-based entry/startup vibe over the framed screenshot-heavy top fold.
Action taken:
- hero switched to use `PlaygroundHeroCanvas`
- kept new landing structure/copy, but moved the visual style back toward orb glow / portal / startup energy
Touched:
- `src/screens/playground/hermes-world-landing.tsx`
- `src/screens/playground/components/playground-hero-canvas.tsx` (used, not necessarily modified)
### 7. HermesWorld copy cleanup
Removed more workspace-local carryover text from the landing.
Examples changed:
- `Hermes Workspace Experiment // Persistent Agent World`
`HermesWorld Preview // Persistent Agent World`
- `Hermes Workspace Connected`
`Live World Build`
### 8. Local preview/dev process debugging happened repeatedly
Root cause for a lot of weirdness:
- multiple Vite dev servers were fighting over `routeTree.gen.ts`
- this caused reload thrash, aborted requests, and “stopping mid-turn” feelings during local preview
Key reality:
- 3002/3003 reliability was not just “the site is broken”, it was also local dev process instability
- detached/nohup relaunches were used at points to keep things alive longer
### 9. Character/graphics pipeline scaffolding started in code
This was the first real gameplay/visual upgrade setup work.
Added:
- `src/screens/playground/lib/character-config.ts`
- `src/screens/playground/components/player-character.tsx`
- `src/screens/playground/components/npc-character.tsx`
- `src/screens/playground/components/playground-glb-body.tsx`
- `src/screens/playground/components/playground-player-glb.tsx`
- rewrote/normalized `src/screens/playground/components/playground-npc-glb.tsx`
- `public/assets/hermesworld/characters/README.md`
What this means:
- canonical asset path now exists for believable humanoid GLBs
- canonical path:
- `/public/assets/hermesworld/characters/<id>.glb`
- legacy `/avatars-3d/<id>.glb` remains as fallback
### 10. Execution docs/specs were created
Created / updated:
- `docs/hermesworld/landing-page-spec.md`
- `docs/hermesworld/graphics-usability-plan.md`
- `docs/hermesworld/agora-believable-checklist.md`
These docs now encode the sessions design/implementation direction.
## What was accomplished, distilled
This session did **four big things**:
1. **Turned HermesWorld landing into a real standalone launch page** instead of a workspace-fragment/app-pane.
2. **Removed or traced most of the workspace chrome leaks** that kept showing up in HermesWorld surfaces.
3. **Stabilized the local direction** around world-first marketing, GitHub/public links, and the glowing-orb entry vibe.
4. **Started the real graphics/character pipeline** so the next session can move from planning into visible world upgrades.
## What still needs to be tackled for the redesign
### A. Final HermesWorld landing cleanup
Still verify/fix:
- no remaining root overlay leaks on `/hermes-world` or `/playground`
- no bottom-right usage/session pill anywhere on HermesWorld/game surfaces
- no mobile-access prompt / other workspace popups on HermesWorld routes
- no sidebar overlap edge cases on `/playground`
### B. Re-verify local previews cleanly
Need a fresh sanity pass on:
- `http://127.0.0.1:3002/hermes-world`
- `http://127.0.0.1:3002/playground`
- optional `3003` behavior if still used as redirect/entry
Need to ensure:
- landing looks correct
- game route has no overlapping HUD with sidebar open
- no stale cached bundle behavior
### C. Continue the real graphics redesign
Next actual game-side work should be:
1. integrate character boundaries into `playground-world-3d.tsx`
2. mount first `PlayerCharacter` / `NpcCharacter` path in the live Agora scene
3. begin **Agora Believable** pass:
- plaza composition
- better central monument
- stronger paths/ground
- better lighting/fog
- better NPC placement
4. start replacing toy-like placeholder figures with believable humanoid pipeline
### D. Character asset pipeline needs real assets next
Need actual GLBs now, not just scaffolding.
Priority assets:
- `player-adventurer.glb`
- `oracle-scholar.glb`
- `forge-blacksmith.glb`
- `guard-knight.glb`
- `merchant-villager.glb`
- `villager-common.glb`
Recommended source path:
- Ready Player Me or similar base characters
- Mixamo for idle/walk/talk/use animations
- optimize to browser-safe GLB assets
### E. HUD/readability pass still needs deeper work
The sidebar-aware offset work started, but the full usability pass is still ahead:
- objective widget
- minimap polish
- action bar clarity
- interaction prompts
- NPC labels
- less prototype noise
## Most important next task for Opus
**Do not restart from brand strategy.**
The next session should continue directly into:
### Primary next task
Refactor `playground-world-3d.tsx` to mount the new character boundaries into Agora and start the first visible believable-character pass.
### Immediate sub-steps
1. inspect current player/NPC rendering in `playground-world-3d.tsx`
2. swap one player and one or two NPC stand-ins to the new component boundaries
3. improve Agora composition around those characters
4. re-check HUD overlap and root overlays in real local preview
## Files most likely to touch next
Landing / route chrome:
- `src/screens/playground/hermes-world-landing.tsx`
- `src/components/workspace-shell.tsx`
- `src/routes/__root.tsx`
Game / graphics:
- `src/screens/playground/components/playground-world-3d.tsx`
- `src/screens/playground/components/playground-hud.tsx`
- `src/screens/playground/components/playground-chat.tsx`
- `src/screens/playground/playground-screen.tsx`
Character pipeline:
- `src/screens/playground/lib/character-config.ts`
- `src/screens/playground/components/player-character.tsx`
- `src/screens/playground/components/npc-character.tsx`
- `src/screens/playground/components/playground-glb-body.tsx`
- `src/screens/playground/components/playground-player-glb.tsx`
- `src/screens/playground/components/playground-npc-glb.tsx`
- `public/assets/hermesworld/characters/*`
Planning docs:
- `docs/hermesworld/landing-page-spec.md`
- `docs/hermesworld/graphics-usability-plan.md`
- `docs/hermesworld/agora-believable-checklist.md`
## Local-state warning
This is still **local-only** and likely **uncommitted**.
There are multiple in-progress modifications in the repo. Do not assume a clean branch. Be careful not to clobber unrelated local work while continuing.
## One-line resume prompt
Resume the HermesWorld redesign from the current local-only state. First verify the landing/game surfaces are free of workspace overlay leaks, then continue the **Agora Believable** pass by wiring the new character pipeline into `playground-world-3d.tsx` and replacing placeholder figures with the first believable humanoid boundaries.

View File

@@ -0,0 +1,49 @@
# PR Triage Mission — 2026-05-05
**Repo:** outsourc-e/hermes-workspace
**Goal:** triage all 19 open PRs, produce structured findings per PR, NO AUTO-MERGE.
**Reporter:** Aurora (main session)
**Lead time:** within this session (~30-45 min for first reports back)
## Hard rules
1. **Do NOT merge any PR.** Eric must approve every merge personally.
2. **Do NOT push to main or any contributor branch.**
3. **Do NOT close PRs.** Just classify them.
4. Report back to Aurora with a single concrete checkpoint per PR.
5. If you encounter conflicts, list them; do not resolve them on the PR branch.
## Per-PR deliverable
For each assigned PR, produce one structured block:
```
PR #<num> — <title>
AUTHOR: <login>
CLASSIFICATION: APPROVE_READY | NEEDS_FIX | NEEDS_DISCUSSION | CLOSE_RECOMMEND
DIFF_SUMMARY: <2-3 lines, what it actually changes>
FILES_TOUCHED: <count + key paths>
RISK: low | medium | high — <one-line reason>
TESTS_RUN: <pnpm build / pnpm lint / specific test files / N/A>
TEST_RESULT: pass | fail | not-run — <evidence>
CONFLICTS: none | <files>
BLOCKERS: <or none>
REVIEW_NOTES: <2-4 lines — concrete things Eric should know before merging>
RECOMMENDED_ACTION: merge | request-changes:<what> | close:<why> | discuss:<what>
```
## Lane assignments
Healthy workers and their PR slices (by PR number):
- **swarm3** → 338, 336, 335, 334
- **swarm4** → 332, 330, 327, 325
- **swarm6** → 324, 322, 320, 318
- **swarm10** → 317, 315, 310, 308
- **swarm12** → 307, 301, 299
Quarantined: swarm1 (broken runtime), swarm2/swarm11 (rate-limited 429).
## Checkpoint cadence
Workers must produce one checkpoint per PR and report it back via STATE: HANDOFF blocks. Aurora will collect, summarize for Eric, and Eric decides each merge.

View File

@@ -24,6 +24,29 @@
export interface Env {
PLAYGROUND_HUB: DurableObjectNamespace
PLAYGROUND_ADMIN_TOKEN?: string
}
type AnalyticsEvent = {
type: 'join' | 'leave' | 'chat' | 'world_change'
id: string
name?: string
color?: string
world?: string
text?: string
ts: number
}
type PlayerDailySummary = {
id: string
name?: string
color?: string
firstSeen: number
lastSeen: number
lastWorld?: string
lastChatAt?: number
chats: number
joins: number
}
interface PresenceMsg {
@@ -41,6 +64,7 @@ interface PresenceMsg {
const STALE_AFTER_MS = 12000
const CHAT_RING_MAX = 50
const ADMIN_EVENT_RING_MAX = 250
const PRESENCE_DEDUPE_MS = 50
const RATE_BUCKET_CAP = 30
const RATE_REFILL_PER_SEC = 30
@@ -63,16 +87,25 @@ interface SocketAttach {
lastPresenceTs: number
}
export class PlaygroundHub {
export class PlaygroundHubV2 {
state: DurableObjectState
env: Env
presence = new Map<string, PresenceMsg & { ts: number }>()
chatRing: any[] = []
peakToday = 0
peakDay = ''
lastBroadcastCount = -1
analyticsDay = ''
uniqueToday = new Set<string>()
joinsToday = 0
leavesToday = 0
chatsToday = 0
playerDaily = new Map<string, PlayerDailySummary>()
eventRing: AnalyticsEvent[] = []
constructor(state: DurableObjectState) {
constructor(state: DurableObjectState, env: Env) {
this.state = state
this.env = env
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<{ peak: number; day: string }>('peak')
if (stored) {
@@ -84,6 +117,24 @@ export class PlaygroundHub {
if (presStored) this.presence = new Map(presStored)
const chatStored = await this.state.storage.get<any[]>('chatRing')
if (chatStored) this.chatRing = chatStored
const analyticsStored = await this.state.storage.get<{
day: string
uniqueToday: string[]
joinsToday: number
leavesToday: number
chatsToday: number
playerDaily: Array<[string, PlayerDailySummary]>
eventRing: AnalyticsEvent[]
}>('analytics')
if (analyticsStored) {
this.analyticsDay = analyticsStored.day || ''
this.uniqueToday = new Set(analyticsStored.uniqueToday || [])
this.joinsToday = analyticsStored.joinsToday || 0
this.leavesToday = analyticsStored.leavesToday || 0
this.chatsToday = analyticsStored.chatsToday || 0
this.playerDaily = new Map(analyticsStored.playerDaily || [])
this.eventRing = analyticsStored.eventRing || []
}
})
this.state.blockConcurrencyWhile(async () => {
this.scheduleAlarm()
@@ -103,6 +154,105 @@ export class PlaygroundHub {
} catch {}
}
async persistAnalytics() {
try {
await this.state.storage.put('analytics', {
day: this.analyticsDay,
uniqueToday: [...this.uniqueToday],
joinsToday: this.joinsToday,
leavesToday: this.leavesToday,
chatsToday: this.chatsToday,
playerDaily: [...this.playerDaily.entries()],
eventRing: this.eventRing,
})
} catch {}
}
ensureAnalyticsDay(ts = Date.now()) {
const day = new Date(ts).toISOString().slice(0, 10)
if (this.analyticsDay && this.analyticsDay === day) return
this.analyticsDay = day
this.uniqueToday = new Set()
this.joinsToday = 0
this.leavesToday = 0
this.chatsToday = 0
this.playerDaily = new Map()
this.eventRing = []
}
notePlayer(
id: string,
patch: Partial<PlayerDailySummary> & { name?: string; color?: string; lastWorld?: string },
ts = Date.now(),
) {
this.ensureAnalyticsDay(ts)
const current = this.playerDaily.get(id)
const next: PlayerDailySummary = current
? {
...current,
...patch,
name: patch.name ?? current.name,
color: patch.color ?? current.color,
lastSeen: ts,
lastWorld: patch.lastWorld ?? current.lastWorld,
lastChatAt: patch.lastChatAt ?? current.lastChatAt,
chats: patch.chats ?? current.chats,
joins: patch.joins ?? current.joins,
}
: {
id,
name: patch.name,
color: patch.color,
firstSeen: ts,
lastSeen: ts,
lastWorld: patch.lastWorld,
lastChatAt: patch.lastChatAt,
chats: patch.chats ?? 0,
joins: patch.joins ?? 0,
}
this.playerDaily.set(id, next)
this.uniqueToday.add(id)
}
pushEvent(event: AnalyticsEvent) {
this.ensureAnalyticsDay(event.ts)
this.eventRing.push(event)
if (this.eventRing.length > ADMIN_EVENT_RING_MAX) {
this.eventRing = this.eventRing.slice(-ADMIN_EVENT_RING_MAX)
}
}
activePlayersWithin(ms: number) {
const cutoff = Date.now() - ms
let count = 0
for (const p of this.playerDaily.values()) {
if (p.lastSeen >= cutoff) count += 1
}
return count
}
adminStats() {
this.ensureAnalyticsDay()
const recentPlayers = [...this.playerDaily.values()]
.sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, 50)
return {
online: this.presence.size,
byWorld: this.byWorld(),
peakToday: this.peakToday,
peakDay: this.peakDay,
uniqueToday: this.uniqueToday.size,
joinsToday: this.joinsToday,
leavesToday: this.leavesToday,
chatsToday: this.chatsToday,
activeLast15m: this.activePlayersWithin(15 * 60 * 1000),
activeLast60m: this.activePlayersWithin(60 * 60 * 1000),
recentPlayers,
recentEvents: this.eventRing.slice(-100).reverse(),
ts: Date.now(),
}
}
// ───── Alarm-driven prune ─────
async scheduleAlarm() {
const cur = await this.state.storage.getAlarm()
@@ -258,6 +408,15 @@ export class PlaygroundHub {
})
}
const adminHeader = request.headers.get('authorization') || ''
const adminOk = !!this.env.PLAYGROUND_ADMIN_TOKEN && adminHeader === `Bearer ${this.env.PLAYGROUND_ADMIN_TOKEN}`
if (url.pathname === '/admin/stats') {
if (!adminOk) return new Response('unauthorized', { status: 401, headers: cors })
return Response.json(this.adminStats(), {
headers: { ...cors, 'cache-control': 'no-cache' },
})
}
// ───── HTTP polling endpoints (reliable fallback for WebSockets) ─────
// POST /presence body: { id, name, color, world, x, y, z, yaw, avatar?, lastChatAt? }
// Updates presence + returns { presences: [...other-players-in-world], chats: [...recent], count, byWorld, peakToday }
@@ -268,14 +427,40 @@ export class PlaygroundHub {
const now = Date.now()
const world = (body.world || body.worldId) as string | undefined
const wire: PresenceMsg & { ts: number } = { ...body, kind: 'presence', ts: now }
const wasNew = !this.presence.has(body.id)
const prior = this.presence.get(body.id)
const wasNew = !prior
this.presence.set(body.id, wire)
this.notePlayer(body.id, {
name: typeof body.name === 'string' ? body.name : undefined,
color: typeof body.color === 'string' ? body.color : undefined,
lastWorld: world,
joins: wasNew ? (this.playerDaily.get(body.id)?.joins || 0) + 1 : (this.playerDaily.get(body.id)?.joins || 0),
}, now)
if (wasNew) {
this.joinsToday += 1
this.pushEvent({
type: 'join',
id: body.id,
name: typeof body.name === 'string' ? body.name : undefined,
color: typeof body.color === 'string' ? body.color : undefined,
world,
ts: now,
})
await this.bumpPeak()
this.maybeBroadcastCount()
} else if ((prior.world || prior.worldId) !== world) {
this.pushEvent({
type: 'world_change',
id: body.id,
name: typeof body.name === 'string' ? body.name : undefined,
color: typeof body.color === 'string' ? body.color : undefined,
world,
ts: now,
})
}
// Persist async — don't block the response
this.persistPresence().catch(() => {})
this.persistAnalytics().catch(() => {})
// Mirror to any active WebSockets (so clients on either transport see each other)
this.broadcast(null, wire, { world })
// Return: presences in same world (excluding caller), recent chats, count summary
@@ -316,7 +501,25 @@ export class PlaygroundHub {
body.ts = Date.now()
this.chatRing.push(body)
if (this.chatRing.length > CHAT_RING_MAX) this.chatRing.shift()
this.chatsToday += 1
this.notePlayer(body.id, {
name: typeof body.name === 'string' ? body.name : undefined,
color: typeof body.color === 'string' ? body.color : undefined,
lastWorld: (body.world || body.worldId) as string | undefined,
lastChatAt: body.ts,
chats: (this.playerDaily.get(body.id)?.chats || 0) + 1,
}, body.ts)
this.pushEvent({
type: 'chat',
id: body.id,
name: typeof body.name === 'string' ? body.name : undefined,
color: typeof body.color === 'string' ? body.color : undefined,
world: (body.world || body.worldId) as string | undefined,
text: typeof body.text === 'string' ? body.text.slice(0, 160) : undefined,
ts: body.ts,
})
this.persistChat().catch(() => {})
this.persistAnalytics().catch(() => {})
const world = (body.world || body.worldId) as string | undefined
this.broadcast(null, body, { world })
return Response.json({ ok: true, ts: body.ts }, { headers: cors })
@@ -330,9 +533,24 @@ export class PlaygroundHub {
const prior = this.presence.get(body.id)
const world = (prior?.world || prior?.worldId) as string | undefined
this.presence.delete(body.id)
this.leavesToday += 1
this.notePlayer(body.id, {
name: typeof prior?.name === 'string' ? prior.name : undefined,
color: typeof prior?.color === 'string' ? prior.color : undefined,
lastWorld: world,
})
this.pushEvent({
type: 'leave',
id: body.id,
name: typeof prior?.name === 'string' ? prior.name : undefined,
color: typeof prior?.color === 'string' ? prior.color : undefined,
world,
ts: Date.now(),
})
this.broadcast(null, { kind: 'leave', id: body.id }, { world })
this.maybeBroadcastCount()
this.persistPresence().catch(() => {})
this.persistAnalytics().catch(() => {})
return Response.json({ ok: true }, { headers: cors })
}
@@ -398,15 +616,27 @@ export class PlaygroundHub {
meta.world = world
this.saveAttach(socket, meta)
const wire: PresenceMsg & { ts: number } = { ...msg, ts: now }
const wasNew = !this.presence.has(msg.id)
const prior = this.presence.get(msg.id)
const wasNew = !prior
this.presence.set(msg.id, wire)
this.notePlayer(msg.id, {
name: typeof msg.name === 'string' ? msg.name : undefined,
color: typeof msg.color === 'string' ? msg.color : undefined,
lastWorld: world,
joins: wasNew ? (this.playerDaily.get(msg.id)?.joins || 0) + 1 : (this.playerDaily.get(msg.id)?.joins || 0),
}, now)
if (wasNew) {
this.joinsToday += 1
this.pushEvent({ type: 'join', id: msg.id, name: msg.name, color: msg.color, world, ts: now })
await this.bumpPeak()
this.maybeBroadcastCount()
} else if ((prior.world || prior.worldId) !== world) {
this.pushEvent({ type: 'world_change', id: msg.id, name: msg.name, color: msg.color, world, ts: now })
}
// Persist periodically (every ~5 presence updates per id is enough).
// We keep this synchronous-ish for correctness on hibernation.
await this.persistPresence()
await this.persistAnalytics()
this.broadcast(socket, wire, { world })
} else if (msg.kind === 'chat' && typeof msg.id === 'string') {
if (typeof msg.text === 'string' && msg.text.length > 240) {
@@ -414,7 +644,25 @@ export class PlaygroundHub {
}
this.chatRing.push(msg)
if (this.chatRing.length > CHAT_RING_MAX) this.chatRing.shift()
this.chatsToday += 1
this.notePlayer(msg.id, {
name: typeof msg.name === 'string' ? msg.name : undefined,
color: typeof msg.color === 'string' ? msg.color : undefined,
lastWorld: (msg.world || msg.worldId) as string | undefined,
lastChatAt: typeof msg.ts === 'number' ? msg.ts : Date.now(),
chats: (this.playerDaily.get(msg.id)?.chats || 0) + 1,
}, typeof msg.ts === 'number' ? msg.ts : Date.now())
this.pushEvent({
type: 'chat',
id: msg.id,
name: typeof msg.name === 'string' ? msg.name : undefined,
color: typeof msg.color === 'string' ? msg.color : undefined,
world: (msg.world || msg.worldId) as string | undefined,
text: typeof msg.text === 'string' ? msg.text.slice(0, 160) : undefined,
ts: typeof msg.ts === 'number' ? msg.ts : Date.now(),
})
await this.persistChat()
await this.persistAnalytics()
const world = (msg.world || msg.worldId) as string | undefined
this.broadcast(socket, msg, { world })
this.saveAttach(socket, meta)
@@ -422,7 +670,22 @@ export class PlaygroundHub {
const prior = this.presence.get(msg.id)
const world = (prior?.world || prior?.worldId) as string | undefined
this.presence.delete(msg.id)
this.leavesToday += 1
this.notePlayer(msg.id, {
name: typeof prior?.name === 'string' ? prior.name : undefined,
color: typeof prior?.color === 'string' ? prior.color : undefined,
lastWorld: world,
})
this.pushEvent({
type: 'leave',
id: msg.id,
name: typeof prior?.name === 'string' ? prior.name : undefined,
color: typeof prior?.color === 'string' ? prior.color : undefined,
world,
ts: Date.now(),
})
await this.persistPresence()
await this.persistAnalytics()
this.broadcast(socket, msg, { world })
this.maybeBroadcastCount()
this.saveAttach(socket, meta)

View File

@@ -5,8 +5,12 @@ compatibility_flags = ["nodejs_compat"]
[[durable_objects.bindings]]
name = "PLAYGROUND_HUB"
class_name = "PlaygroundHub"
class_name = "PlaygroundHubV2"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["PlaygroundHub"]
[[migrations]]
tag = "v2"
renamed_classes = [{from = "PlaygroundHub", to = "PlaygroundHubV2"}]

View File

@@ -0,0 +1,39 @@
# HermesWorld Character Assets
Drop character GLBs here.
## Canonical path
Characters should live at:
- `/public/assets/hermesworld/characters/<id>.glb`
Examples:
- `player-adventurer.glb`
- `oracle-scholar.glb`
- `forge-blacksmith.glb`
- `guard-knight.glb`
- `merchant-villager.glb`
- `villager-common.glb`
## Source pipeline
Recommended source order:
1. Ready Player Me or similar believable humanoid base
2. Mixamo animation clips
3. GLB export
4. browser optimization
## First animations to support
- idle
- walk
- run
- talk
- inspect
- use
## Naming rules
- lowercase kebab-case ids
- keep one archetype per file
- prefer shared rigs
- keep texture/material counts low

View File

@@ -0,0 +1,47 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1024" height="1024" rx="224" fill="#07080D"/>
<rect x="42" y="42" width="940" height="940" rx="190" fill="url(#bg)" stroke="url(#border)" stroke-width="12"/>
<circle cx="512" cy="512" r="286" fill="#0B1118" stroke="url(#ring)" stroke-width="18"/>
<circle cx="512" cy="512" r="220" stroke="#22D3EE" stroke-opacity="0.26" stroke-width="8" stroke-dasharray="28 22"/>
<path d="M512 238C585 320 633 394 633 491C633 604 575 681 512 776C449 681 391 604 391 491C391 394 439 320 512 238Z" fill="url(#portal)" stroke="#F8E4AC" stroke-width="12" stroke-linejoin="round"/>
<path d="M512 345C557 401 582 447 582 505C582 570 552 619 512 678C472 619 442 570 442 505C442 447 467 401 512 345Z" fill="#071018" stroke="#22D3EE" stroke-width="10" stroke-linejoin="round"/>
<path d="M512 301V716" stroke="#F8E4AC" stroke-width="18" stroke-linecap="round"/>
<path d="M454 462C488 438 536 438 570 462" stroke="#F8E4AC" stroke-width="16" stroke-linecap="round"/>
<path d="M454 532C488 556 536 556 570 532" stroke="#F8E4AC" stroke-width="16" stroke-linecap="round"/>
<path d="M371 371C286 346 211 374 151 455C246 458 323 438 383 393L371 371Z" fill="url(#wingL)" stroke="#F8E4AC" stroke-width="10" stroke-linejoin="round"/>
<path d="M653 371C738 346 813 374 873 455C778 458 701 438 641 393L653 371Z" fill="url(#wingR)" stroke="#F8E4AC" stroke-width="10" stroke-linejoin="round"/>
<path d="M179 431C250 420 306 397 358 360" stroke="#071018" stroke-width="14" stroke-linecap="round" opacity="0.58"/>
<path d="M845 431C774 420 718 397 666 360" stroke="#071018" stroke-width="14" stroke-linecap="round" opacity="0.58"/>
<path d="M354 658L512 746L670 658" stroke="#22D3EE" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
<circle cx="512" cy="512" r="36" fill="#F8E4AC" stroke="#07080D" stroke-width="10"/>
<circle cx="512" cy="512" r="18" fill="#22D3EE"/>
<defs>
<linearGradient id="bg" x1="128" y1="76" x2="896" y2="958" gradientUnits="userSpaceOnUse">
<stop stop-color="#10151F"/>
<stop offset="0.48" stop-color="#080B12"/>
<stop offset="1" stop-color="#050608"/>
</linearGradient>
<linearGradient id="border" x1="140" y1="80" x2="880" y2="944" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8E4AC" stop-opacity="0.75"/>
<stop offset="0.45" stop-color="#22D3EE" stop-opacity="0.55"/>
<stop offset="1" stop-color="#A78BFA" stop-opacity="0.45"/>
</linearGradient>
<linearGradient id="ring" x1="276" y1="222" x2="760" y2="814" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8E4AC"/>
<stop offset="1" stop-color="#22D3EE"/>
</linearGradient>
<linearGradient id="portal" x1="512" y1="238" x2="512" y2="776" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8E4AC"/>
<stop offset="0.45" stop-color="#D9B35F"/>
<stop offset="1" stop-color="#22D3EE"/>
</linearGradient>
<linearGradient id="wingL" x1="149" y1="350" x2="388" y2="457" gradientUnits="userSpaceOnUse">
<stop stop-color="#22D3EE"/>
<stop offset="1" stop-color="#F8E4AC"/>
</linearGradient>
<linearGradient id="wingR" x1="875" y1="350" x2="636" y2="457" gradientUnits="userSpaceOnUse">
<stop stop-color="#22D3EE"/>
<stop offset="1" stop-color="#F8E4AC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -62,6 +62,9 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const search = useRouterState({
select: (state) => state.location.search,
})
const isElectron = useMemo(
() =>
typeof navigator !== 'undefined' && /Electron/.test(navigator.userAgent),
@@ -185,9 +188,13 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
const isOnChatRoute = Boolean(chatMatch) || pathname === '/new'
const isOnTerminalRoute = pathname.startsWith('/terminal')
const isOnPlaygroundRoute = pathname === '/playground' || pathname.startsWith('/playground/')
const isOnHermesWorldLandingRoute = pathname === '/hermes-world' || pathname.startsWith('/hermes-world/') || pathname === '/world' || pathname.startsWith('/world/')
const isEmbeddedSurface =
search?.embed === '1' || search?.embed === 'true' || search?.mode === 'embed'
const isChromeFreeSurface = isEmbeddedSurface || isOnHermesWorldLandingRoute
const hideChatSidebar = isOnChatRoute && chatFocusMode
const showDesktopSidebarBackdrop =
!isMobile && !isOnChatRoute && !sidebarCollapsed
!isChromeFreeSurface && !isMobile && !isOnChatRoute && !sidebarCollapsed
const isNewChat = activeFriendlyId === 'new'
@@ -281,6 +288,13 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
window.removeEventListener(SIDEBAR_TOGGLE_EVENT, handleToggleEvent)
}, [isMobile, setSidebarCollapsed, toggleSidebar])
// Public/launch surfaces should behave like normal web pages, not app-shell panes.
// This keeps /hermes-world and /world scrollable at the document level and avoids
// local-only workspace chrome for X/GitHub traffic.
if (isChromeFreeSurface) {
return <>{children}</>
}
// Show login screen if auth is required and not authenticated
if (authState.authRequired && !authState.authenticated) {
return <LoginScreen />
@@ -328,12 +342,12 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
<div
className={cn(
'grid h-full grid-cols-1 grid-rows-[minmax(0,1fr)] overflow-hidden',
hideChatSidebar ? 'md:grid-cols-1' : 'md:grid-cols-[auto_1fr]',
hideChatSidebar || isChromeFreeSurface ? 'md:grid-cols-1' : 'md:grid-cols-[auto_1fr]',
)}
>
{/* Activity ticker bar */}
{/* Persistent sidebar */}
{!isMobile && !hideChatSidebar && (
{!isChromeFreeSurface && !isMobile && !hideChatSidebar && (
<div className="relative z-30">
<ChatSidebar
sessions={sessions}
@@ -363,6 +377,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
isMobile && !isOnChatRoute
? 'pb-[calc(var(--tabbar-h,0px)+0.5rem)]'
: !isMobile &&
!isChromeFreeSurface &&
!isOnChatRoute &&
settings.showSystemMetricsFooter
? 'pb-7'
@@ -400,7 +415,8 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
<div
className={[
'page-transition h-full flex flex-col',
'page-transition flex flex-col',
isChromeFreeSurface ? 'min-h-full' : 'h-full',
slideClass,
isOnTerminalRoute ? 'hidden' : '',
]
@@ -408,6 +424,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
.join(' ')}
>
{isMobile &&
!isChromeFreeSurface &&
!isOnChatRoute &&
!isOnTerminalRoute &&
mobilePageTitle && <MobilePageHeader title={mobilePageTitle} />}
@@ -416,11 +433,11 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
</main>
{/* Chat panel — visible on non-chat routes (but not in HermesWorld, which has its own in-game chat) */}
{!isOnChatRoute && !isOnPlaygroundRoute && !isMobile && <ChatPanel />}
{!isOnChatRoute && !isOnPlaygroundRoute && !isChromeFreeSurface && !isMobile && <ChatPanel />}
</div>
{/* Floating chat toggle — visible on non-chat routes (but not in HermesWorld) */}
{!isOnChatRoute && !isOnPlaygroundRoute && !isMobile && <ChatPanelToggle />}
{!isChromeFreeSurface && !isOnChatRoute && !isOnPlaygroundRoute && !isMobile && <ChatPanelToggle />}
{showDesktopSidebarBackdrop ? (
<button
@@ -436,11 +453,11 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
) : null}
</div>
<MobileHamburgerMenu />
{!isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
{!isChromeFreeSurface ? <MobileHamburgerMenu /> : null}
{!isChromeFreeSurface && !isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
<SystemMetricsFooter leftOffsetPx={sidebarCollapsed ? 48 : 300} />
) : null}
<CommandPalette pathname={pathname} sessions={sessions} />
{!isChromeFreeSurface ? <CommandPalette pathname={pathname} sessions={sessions} /> : null}
</>
)
}

View File

@@ -1,6 +1,6 @@
const BASE = '/api/claude-tasks'
export type TaskColumn = 'backlog' | 'todo' | 'in_progress' | 'review' | 'done'
export type TaskColumn = 'backlog' | 'todo' | 'in_progress' | 'review' | 'blocked' | 'done'
export type TaskPriority = 'high' | 'medium' | 'low'
export type ClaudeTask = {
@@ -108,14 +108,15 @@ export async function moveTask(taskId: string, column: TaskColumn, movedBy = 'us
}
export const COLUMN_LABELS: Record<TaskColumn, string> = {
backlog: 'Backlog',
todo: 'Todo',
in_progress: 'In Progress',
backlog: 'Triage',
todo: 'Ready',
in_progress: 'Running',
review: 'Review',
blocked: 'Blocked',
done: 'Done',
}
export const COLUMN_ORDER: Array<TaskColumn> = ['backlog', 'todo', 'in_progress', 'review', 'done']
export const COLUMN_ORDER: Array<TaskColumn> = ['backlog', 'todo', 'in_progress', 'review', 'blocked', 'done']
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
high: '#ef4444',
@@ -128,6 +129,7 @@ export const COLUMN_COLORS: Record<TaskColumn, string> = {
todo: '#3b82f6',
in_progress: '#f97316',
review: '#a855f7',
blocked: '#ef4444',
done: '#22c55e',
}

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as WorldRouteImport } from './routes/world'
import { Route as TerminalRouteImport } from './routes/terminal'
import { Route as TasksRouteImport } from './routes/tasks'
import { Route as Swarm2RouteImport } from './routes/swarm2'
@@ -21,6 +22,7 @@ import { Route as OperationsRouteImport } from './routes/operations'
import { Route as MemoryRouteImport } from './routes/memory'
import { Route as McpRouteImport } from './routes/mcp'
import { Route as JobsRouteImport } from './routes/jobs'
import { Route as HermesWorldRouteImport } from './routes/hermes-world'
import { Route as FilesRouteImport } from './routes/files'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as ConductorRouteImport } from './routes/conductor'
@@ -69,6 +71,7 @@ import { Route as ApiProviderUsageRouteImport } from './routes/api/provider-usag
import { Route as ApiPreviewFileRouteImport } from './routes/api/preview-file'
import { Route as ApiPluginsRouteImport } from './routes/api/plugins'
import { Route as ApiPlaygroundNpcRouteImport } from './routes/api/playground-npc'
import { Route as ApiPlaygroundAdminRouteImport } from './routes/api/playground-admin'
import { Route as ApiPingRouteImport } from './routes/api/ping'
import { Route as ApiPathsRouteImport } from './routes/api/paths'
import { Route as ApiModelsRouteImport } from './routes/api/models'
@@ -142,6 +145,11 @@ import { Route as ApiSessionsSessionKeyActiveRunRouteImport } from './routes/api
import { Route as ApiMcpHubSourcesIdRouteImport } from './routes/api/mcp/hub-sources.$id'
import { Route as ApiMcpNameLogsRouteImport } from './routes/api/mcp/$name.logs'
const WorldRoute = WorldRouteImport.update({
id: '/world',
path: '/world',
getParentRoute: () => rootRouteImport,
} as any)
const TerminalRoute = TerminalRouteImport.update({
id: '/terminal',
path: '/terminal',
@@ -202,6 +210,11 @@ const JobsRoute = JobsRouteImport.update({
path: '/jobs',
getParentRoute: () => rootRouteImport,
} as any)
const HermesWorldRoute = HermesWorldRouteImport.update({
id: '/hermes-world',
path: '/hermes-world',
getParentRoute: () => rootRouteImport,
} as any)
const FilesRoute = FilesRouteImport.update({
id: '/files',
path: '/files',
@@ -443,6 +456,11 @@ const ApiPlaygroundNpcRoute = ApiPlaygroundNpcRouteImport.update({
path: '/api/playground-npc',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPlaygroundAdminRoute = ApiPlaygroundAdminRouteImport.update({
id: '/api/playground-admin',
path: '/api/playground-admin',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPingRoute = ApiPingRouteImport.update({
id: '/api/ping',
path: '/api/ping',
@@ -813,6 +831,7 @@ export interface FileRoutesByFullPath {
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
'/mcp': typeof McpRoute
'/memory': typeof MemoryRoute
@@ -825,6 +844,7 @@ export interface FileRoutesByFullPath {
'/swarm2': typeof Swarm2Route
'/tasks': typeof TasksRoute
'/terminal': typeof TerminalRoute
'/world': typeof WorldRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -852,6 +872,7 @@ export interface FileRoutesByFullPath {
'/api/models': typeof ApiModelsRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
'/api/playground-npc': typeof ApiPlaygroundNpcRoute
'/api/plugins': typeof ApiPluginsRoute
'/api/preview-file': typeof ApiPreviewFileRoute
@@ -947,6 +968,7 @@ export interface FileRoutesByTo {
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
'/mcp': typeof McpRoute
'/memory': typeof MemoryRoute
@@ -958,6 +980,7 @@ export interface FileRoutesByTo {
'/swarm2': typeof Swarm2Route
'/tasks': typeof TasksRoute
'/terminal': typeof TerminalRoute
'/world': typeof WorldRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -985,6 +1008,7 @@ export interface FileRoutesByTo {
'/api/models': typeof ApiModelsRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
'/api/playground-npc': typeof ApiPlaygroundNpcRoute
'/api/plugins': typeof ApiPluginsRoute
'/api/preview-file': typeof ApiPreviewFileRoute
@@ -1081,6 +1105,7 @@ export interface FileRoutesById {
'/conductor': typeof ConductorRoute
'/dashboard': typeof DashboardRoute
'/files': typeof FilesRoute
'/hermes-world': typeof HermesWorldRoute
'/jobs': typeof JobsRoute
'/mcp': typeof McpRoute
'/memory': typeof MemoryRoute
@@ -1093,6 +1118,7 @@ export interface FileRoutesById {
'/swarm2': typeof Swarm2Route
'/tasks': typeof TasksRoute
'/terminal': typeof TerminalRoute
'/world': typeof WorldRoute
'/api/artifacts': typeof ApiArtifactsRouteWithChildren
'/api/auth': typeof ApiAuthRoute
'/api/auth-check': typeof ApiAuthCheckRoute
@@ -1120,6 +1146,7 @@ export interface FileRoutesById {
'/api/models': typeof ApiModelsRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/playground-admin': typeof ApiPlaygroundAdminRoute
'/api/playground-npc': typeof ApiPlaygroundNpcRoute
'/api/plugins': typeof ApiPluginsRoute
'/api/preview-file': typeof ApiPreviewFileRoute
@@ -1217,6 +1244,7 @@ export interface FileRouteTypes {
| '/conductor'
| '/dashboard'
| '/files'
| '/hermes-world'
| '/jobs'
| '/mcp'
| '/memory'
@@ -1229,6 +1257,7 @@ export interface FileRouteTypes {
| '/swarm2'
| '/tasks'
| '/terminal'
| '/world'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1256,6 +1285,7 @@ export interface FileRouteTypes {
| '/api/models'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
| '/api/playground-npc'
| '/api/plugins'
| '/api/preview-file'
@@ -1351,6 +1381,7 @@ export interface FileRouteTypes {
| '/conductor'
| '/dashboard'
| '/files'
| '/hermes-world'
| '/jobs'
| '/mcp'
| '/memory'
@@ -1362,6 +1393,7 @@ export interface FileRouteTypes {
| '/swarm2'
| '/tasks'
| '/terminal'
| '/world'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1389,6 +1421,7 @@ export interface FileRouteTypes {
| '/api/models'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
| '/api/playground-npc'
| '/api/plugins'
| '/api/preview-file'
@@ -1484,6 +1517,7 @@ export interface FileRouteTypes {
| '/conductor'
| '/dashboard'
| '/files'
| '/hermes-world'
| '/jobs'
| '/mcp'
| '/memory'
@@ -1496,6 +1530,7 @@ export interface FileRouteTypes {
| '/swarm2'
| '/tasks'
| '/terminal'
| '/world'
| '/api/artifacts'
| '/api/auth'
| '/api/auth-check'
@@ -1523,6 +1558,7 @@ export interface FileRouteTypes {
| '/api/models'
| '/api/paths'
| '/api/ping'
| '/api/playground-admin'
| '/api/playground-npc'
| '/api/plugins'
| '/api/preview-file'
@@ -1619,6 +1655,7 @@ export interface RootRouteChildren {
ConductorRoute: typeof ConductorRoute
DashboardRoute: typeof DashboardRoute
FilesRoute: typeof FilesRoute
HermesWorldRoute: typeof HermesWorldRoute
JobsRoute: typeof JobsRoute
McpRoute: typeof McpRoute
MemoryRoute: typeof MemoryRoute
@@ -1631,6 +1668,7 @@ export interface RootRouteChildren {
Swarm2Route: typeof Swarm2Route
TasksRoute: typeof TasksRoute
TerminalRoute: typeof TerminalRoute
WorldRoute: typeof WorldRoute
ApiArtifactsRoute: typeof ApiArtifactsRouteWithChildren
ApiAuthRoute: typeof ApiAuthRoute
ApiAuthCheckRoute: typeof ApiAuthCheckRoute
@@ -1658,6 +1696,7 @@ export interface RootRouteChildren {
ApiModelsRoute: typeof ApiModelsRoute
ApiPathsRoute: typeof ApiPathsRoute
ApiPingRoute: typeof ApiPingRoute
ApiPlaygroundAdminRoute: typeof ApiPlaygroundAdminRoute
ApiPlaygroundNpcRoute: typeof ApiPlaygroundNpcRoute
ApiPluginsRoute: typeof ApiPluginsRoute
ApiPreviewFileRoute: typeof ApiPreviewFileRoute
@@ -1723,6 +1762,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/world': {
id: '/world'
path: '/world'
fullPath: '/world'
preLoaderRoute: typeof WorldRouteImport
parentRoute: typeof rootRouteImport
}
'/terminal': {
id: '/terminal'
path: '/terminal'
@@ -1807,6 +1853,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof JobsRouteImport
parentRoute: typeof rootRouteImport
}
'/hermes-world': {
id: '/hermes-world'
path: '/hermes-world'
fullPath: '/hermes-world'
preLoaderRoute: typeof HermesWorldRouteImport
parentRoute: typeof rootRouteImport
}
'/files': {
id: '/files'
path: '/files'
@@ -2143,6 +2196,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPlaygroundNpcRouteImport
parentRoute: typeof rootRouteImport
}
'/api/playground-admin': {
id: '/api/playground-admin'
path: '/api/playground-admin'
fullPath: '/api/playground-admin'
preLoaderRoute: typeof ApiPlaygroundAdminRouteImport
parentRoute: typeof rootRouteImport
}
'/api/ping': {
id: '/api/ping'
path: '/api/ping'
@@ -2817,6 +2877,7 @@ const rootRouteChildren: RootRouteChildren = {
ConductorRoute: ConductorRoute,
DashboardRoute: DashboardRoute,
FilesRoute: FilesRoute,
HermesWorldRoute: HermesWorldRoute,
JobsRoute: JobsRoute,
McpRoute: McpRoute,
MemoryRoute: MemoryRoute,
@@ -2829,6 +2890,7 @@ const rootRouteChildren: RootRouteChildren = {
Swarm2Route: Swarm2Route,
TasksRoute: TasksRoute,
TerminalRoute: TerminalRoute,
WorldRoute: WorldRoute,
ApiArtifactsRoute: ApiArtifactsRouteWithChildren,
ApiAuthRoute: ApiAuthRoute,
ApiAuthCheckRoute: ApiAuthCheckRoute,
@@ -2856,6 +2918,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiModelsRoute: ApiModelsRoute,
ApiPathsRoute: ApiPathsRoute,
ApiPingRoute: ApiPingRoute,
ApiPlaygroundAdminRoute: ApiPlaygroundAdminRoute,
ApiPlaygroundNpcRoute: ApiPlaygroundNpcRoute,
ApiPluginsRoute: ApiPluginsRoute,
ApiPreviewFileRoute: ApiPreviewFileRoute,

View File

@@ -3,6 +3,7 @@ import {
Outlet,
Scripts,
createRootRoute,
useRouterState,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
@@ -257,6 +258,13 @@ export async function unregisterServiceWorkers({
}
function RootLayout() {
const pathname = useRouterState({ select: (state) => state.location.pathname })
const isHermesWorldLandingRoute =
pathname === '/hermes-world' ||
pathname.startsWith('/hermes-world/') ||
pathname === '/world' ||
pathname.startsWith('/world/')
const isGameSurfaceRoute = isHermesWorldLandingRoute || pathname === '/playground' || pathname.startsWith('/playground/')
const [onboardingComplete, setOnboardingComplete] = useState<boolean | null>(
null,
)
@@ -367,13 +375,14 @@ function RootLayout() {
<Outlet />
</ErrorBoundary>
</WorkspaceShell>
<SearchModal />
{!isHermesWorldLandingRoute ? <SearchModal /> : null}
{/* UsageMeter must be mounted at root so the OPEN_USAGE event from
the search modal's Usage tile has a listener. See #258. */}
<UsageMeter />
<KeyboardShortcutsModal />
<UpdateCenterNotifier />
{rootSurfaceState.showPostOnboardingOverlays ? (
the search modal's Usage tile has a listener. See #258.
But public launch surfaces like HermesWorld should not show app usage chrome. */}
{!isGameSurfaceRoute ? <UsageMeter /> : null}
{!isHermesWorldLandingRoute ? <KeyboardShortcutsModal /> : null}
{!isHermesWorldLandingRoute ? <UpdateCenterNotifier /> : null}
{rootSurfaceState.showPostOnboardingOverlays && !isGameSurfaceRoute ? (
<>
<MobilePromptTrigger />
<OnboardingTour />
@@ -420,6 +429,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
__html: wrapInlineScript(`
(function(){
if (document.getElementById('splash-screen')) return;
if (location.pathname === '/hermes-world' || location.pathname.indexOf('/hermes-world/') === 0 || location.pathname === '/world' || location.pathname.indexOf('/world/') === 0) return;
var bg = '#031A1A', txt = '#F8F1E3', muted = '#9CB2AE', accent = '#FFAC02';
try {
var theme = localStorage.getItem('${THEME_STORAGE_KEY}') || '${DEFAULT_THEME}';

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { isAuthenticated } from '../../server/auth-middleware'
import { deleteTask, getTask, moveTask, updateTask } from '../../server/tasks-store'
import type { TaskColumn, TaskPriority } from '../../server/tasks-store'
import { getClaudeTask, moveClaudeTask, updateClaudeTask } from '../../server/claude-tasks-backend'
import type { TaskColumn, TaskPriority } from '../../server/claude-tasks-backend'
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
@@ -16,6 +16,7 @@ function isTaskColumn(value: unknown): value is TaskColumn {
value === 'todo' ||
value === 'in_progress' ||
value === 'review' ||
value === 'blocked' ||
value === 'done'
)
}
@@ -32,7 +33,7 @@ export const Route = createFileRoute('/api/claude-tasks/$taskId')({
return jsonResponse({ error: 'Unauthorized' }, 401)
}
const task = getTask(params.taskId)
const task = await getClaudeTask(params.taskId)
if (!task) return jsonResponse({ error: 'Task not found' }, 404)
return jsonResponse({ task })
},
@@ -44,7 +45,7 @@ export const Route = createFileRoute('/api/claude-tasks/$taskId')({
try {
const body = (await request.json()) as Record<string, unknown>
const task = updateTask(params.taskId, {
const task = await updateClaudeTask(params.taskId, {
title: typeof body.title === 'string' ? body.title : undefined,
description: typeof body.description === 'string' ? body.description : undefined,
column: isTaskColumn(body.column) ? body.column : undefined,
@@ -52,7 +53,6 @@ export const Route = createFileRoute('/api/claude-tasks/$taskId')({
assignee: body.assignee === null || typeof body.assignee === 'string' ? body.assignee : undefined,
tags: Array.isArray(body.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined,
due_date: body.due_date === null || typeof body.due_date === 'string' ? body.due_date : undefined,
position: typeof body.position === 'number' ? body.position : undefined,
})
if (!task) return jsonResponse({ error: 'Task not found' }, 404)
@@ -62,14 +62,12 @@ export const Route = createFileRoute('/api/claude-tasks/$taskId')({
}
},
DELETE: async ({ request, params }) => {
DELETE: async ({ request }) => {
if (!isAuthenticated(request)) {
return jsonResponse({ error: 'Unauthorized' }, 401)
}
const deleted = deleteTask(params.taskId)
if (!deleted) return jsonResponse({ error: 'Task not found' }, 404)
return jsonResponse({ ok: true })
return jsonResponse({ error: 'Delete is not supported by the shared Agent Kanban backend' }, 405)
},
POST: async ({ request, params }) => {
@@ -85,10 +83,10 @@ export const Route = createFileRoute('/api/claude-tasks/$taskId')({
try {
const body = (await request.json()) as Record<string, unknown>
if (typeof body.column !== 'string') {
if (!isTaskColumn(body.column)) {
return jsonResponse({ error: 'column is required' }, 400)
}
const task = moveTask(params.taskId, body.column as TaskColumn)
const task = await moveClaudeTask(params.taskId, body.column)
if (!task) return jsonResponse({ error: 'Task not found' }, 404)
return jsonResponse({ task })
} catch {

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { isAuthenticated } from '../../server/auth-middleware'
import { createTask, listTasks } from '../../server/tasks-store'
import type { TaskColumn, TaskPriority } from '../../server/tasks-store'
import { createClaudeTask, listClaudeTasks } from '../../server/claude-tasks-backend'
import type { TaskColumn, TaskPriority } from '../../server/claude-tasks-backend'
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
@@ -16,6 +16,7 @@ function isTaskColumn(value: unknown): value is TaskColumn {
value === 'todo' ||
value === 'in_progress' ||
value === 'review' ||
value === 'blocked' ||
value === 'done'
)
}
@@ -33,7 +34,7 @@ export const Route = createFileRoute('/api/claude-tasks')({
}
const url = new URL(request.url)
const tasks = listTasks({
const tasks = await listClaudeTasks({
column: url.searchParams.get('column'),
assignee: url.searchParams.get('assignee'),
priority: url.searchParams.get('priority'),
@@ -54,8 +55,7 @@ export const Route = createFileRoute('/api/claude-tasks')({
return jsonResponse({ error: 'title is required' }, 400)
}
const task = createTask({
id: typeof body.id === 'string' ? body.id : undefined,
const task = await createClaudeTask({
title: body.title,
description: typeof body.description === 'string' ? body.description : '',
column: isTaskColumn(body.column) ? body.column : undefined,
@@ -63,7 +63,6 @@ export const Route = createFileRoute('/api/claude-tasks')({
assignee: typeof body.assignee === 'string' ? body.assignee : null,
tags: Array.isArray(body.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : [],
due_date: typeof body.due_date === 'string' ? body.due_date : null,
position: typeof body.position === 'number' ? body.position : 0,
created_by: typeof body.created_by === 'string' ? body.created_by : 'user',
})

View File

@@ -0,0 +1,54 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
function workerBaseUrl() {
const explicit = (process.env.PLAYGROUND_ADMIN_BASE_URL || '').trim()
if (explicit) return explicit.replace(/\/+$/, '')
const statsUrl = (process.env.VITE_PLAYGROUND_STATS_URL || '').trim()
if (statsUrl) return statsUrl.replace(/\/stats$/, '').replace(/\/+$/, '')
return 'https://hermes-playground-ws.myaurora-agi.workers.dev'
}
export const Route = createFileRoute('/api/playground-admin')({
server: {
handlers: {
GET: async ({ request }) => {
const host = (request.headers.get('host') || '').toLowerCase()
const localOk = host.startsWith('127.0.0.1:') || host.startsWith('localhost:') || host.endsWith('.local:3002')
if (!localOk) {
return json({ ok: false, error: 'Admin stats are only available from a local workspace session.' }, { status: 403 })
}
const token = (process.env.PLAYGROUND_ADMIN_TOKEN || '').trim()
if (!token) {
return json({ ok: false, error: 'PLAYGROUND_ADMIN_TOKEN is not configured.' }, { status: 503 })
}
try {
const res = await fetch(`${workerBaseUrl()}/admin/stats`, {
headers: {
authorization: `Bearer ${token}`,
},
cache: 'no-store',
})
const text = await res.text()
if (!res.ok) {
return json(
{ ok: false, error: `Worker admin request failed (${res.status}): ${text.slice(0, 300)}` },
{ status: res.status },
)
}
return new Response(text, {
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
},
})
} catch (error: any) {
return json({ ok: false, error: error?.message || 'Unknown error' }, { status: 500 })
}
},
},
},
})

View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { usePageTitle } from '@/hooks/use-page-title'
import { HermesWorldLanding } from '@/screens/playground/hermes-world-landing'
export const Route = createFileRoute('/hermes-world')({
ssr: false,
component: HermesWorldRoute,
})
function HermesWorldRoute() {
usePageTitle('HermesWorld — AI Agent RPG')
return <HermesWorldLanding />
}

13
src/routes/world.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { createFileRoute } from '@tanstack/react-router'
import { usePageTitle } from '@/hooks/use-page-title'
import { HermesWorldLanding } from '@/screens/playground/hermes-world-landing'
export const Route = createFileRoute('/world')({
ssr: false,
component: WorldRoute,
})
function WorldRoute() {
usePageTitle('HermesWorld — AI Agent RPG')
return <HermesWorldLanding />
}

View File

@@ -0,0 +1,57 @@
import { GroupProps } from '@react-three/fiber'
import type { CharacterArchetypeId } from '../lib/character-config'
import { HERMESWORLD_CHARACTER_ARCHETYPES } from '../lib/character-config'
type NpcCharacterProps = GroupProps & {
archetypeId: CharacterArchetypeId
accent?: string
}
/**
* Temporary visual scaffold for future GLB NPCs.
*
* This gives us a clean component boundary so Agora can stop rendering every
* character ad hoc inside the giant world scene file.
*/
export function NpcCharacter({ archetypeId, accent, ...props }: NpcCharacterProps) {
const archetype =
HERMESWORLD_CHARACTER_ARCHETYPES.find((entry) => entry.id === archetypeId) ??
HERMESWORLD_CHARACTER_ARCHETYPES[0]
const tint = accent ?? inferTint(archetypeId)
return (
<group {...props}>
<mesh castShadow receiveShadow position={[0, 0.85, 0]}>
<capsuleGeometry args={[0.25, 1.0, 6, 12]} />
<meshStandardMaterial color={tint} roughness={0.62} metalness={0.06} />
</mesh>
<mesh castShadow position={[0, 1.75, 0]}>
<sphereGeometry args={[0.22, 20, 20]} />
<meshStandardMaterial color="#f1c9a5" roughness={0.72} metalness={0.02} />
</mesh>
<mesh position={[0, 2.2, 0]}>
<boxGeometry args={[0.84, 0.06, 0.06]} />
<meshStandardMaterial color="#d9b35f" emissive="#2c2110" emissiveIntensity={0.14} />
</mesh>
</group>
)
}
function inferTint(archetypeId: CharacterArchetypeId): string {
switch (archetypeId) {
case 'oracle-scholar':
return '#8b6cff'
case 'forge-blacksmith':
return '#d97745'
case 'guard-knight':
return '#4773d6'
case 'merchant-villager':
return '#7c9b57'
case 'villager-common':
return '#6f8aa8'
case 'player-adventurer':
default:
return '#4b7bec'
}
}

View File

@@ -0,0 +1,32 @@
import { GroupProps } from '@react-three/fiber'
import { HERMESWORLD_CHARACTER_ARCHETYPES } from '../lib/character-config'
const PLAYER_ARCHETYPE = HERMESWORLD_CHARACTER_ARCHETYPES.find(
(entry) => entry.id === 'player-adventurer',
)
/**
* Placeholder component for the first believable-player pipeline.
*
* Intentionally simple for now: we want a stable integration point before
* wiring a real GLB + animation controller. The next pass should replace this
* with a loaded character model + idle/walk/run/talk animation states.
*/
export function PlayerCharacter(props: GroupProps) {
return (
<group {...props}>
<mesh castShadow receiveShadow position={[0, 0.9, 0]}>
<capsuleGeometry args={[0.28, 1.1, 6, 12]} />
<meshStandardMaterial color="#4b7bec" roughness={0.58} metalness={0.08} />
</mesh>
<mesh castShadow position={[0, 1.85, 0]}>
<sphereGeometry args={[0.24, 24, 24]} />
<meshStandardMaterial color="#f1c9a5" roughness={0.72} metalness={0.02} />
</mesh>
<mesh position={[0, 2.35, 0]}>
<boxGeometry args={[0.9, 0.08, 0.08]} />
<meshStandardMaterial color="#d9b35f" emissive="#3a2b11" emissiveIntensity={0.18} />
</mesh>
</group>
)
}

View File

@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from 'react'
type AdminStats = {
ok?: boolean
error?: string
online: number
byWorld: Record<string, number>
peakToday: number
uniqueToday: number
joinsToday: number
leavesToday: number
chatsToday: number
activeLast15m: number
activeLast60m: number
recentPlayers: Array<{
id: string
name?: string
color?: string
firstSeen: number
lastSeen: number
lastWorld?: string
lastChatAt?: number
chats: number
joins: number
}>
recentEvents: Array<{
type: string
id: string
name?: string
color?: string
world?: string
text?: string
ts: number
}>
ts: number
}
const WORLD_LABELS: Record<string, string> = {
training: 'Training',
agora: 'Agora',
forge: 'Forge',
grove: 'Grove',
oracle: 'Oracle',
arena: 'Arena',
}
const EVENT_STYLES: Record<string, { label: string; tone: string }> = {
join: { label: 'Join', tone: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100' },
leave: { label: 'Leave', tone: 'border-zinc-300/20 bg-white/5 text-zinc-200' },
chat: { label: 'Human chat', tone: 'border-cyan-300/25 bg-cyan-300/10 text-cyan-100' },
world_change: { label: 'Travel', tone: 'border-violet-300/25 bg-violet-300/10 text-violet-100' },
}
function fmtTime(ts?: number) {
if (!ts) return '—'
return new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function fmtAge(ts?: number) {
if (!ts) return '—'
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
return `${Math.floor(minutes / 60)}h ago`
}
export function PlaygroundAdminPanel() {
const [stats, setStats] = useState<AdminStats | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const r = await fetch('/api/playground-admin', { cache: 'no-store' })
const data = (await r.json()) as AdminStats
if (!r.ok || data?.ok === false) throw new Error(data?.error || `HTTP ${r.status}`)
if (!cancelled) {
setStats(data)
setError(null)
}
} catch (e: any) {
if (!cancelled) setError(e?.message || 'Failed to load admin stats')
}
}
load()
const id = window.setInterval(load, 10000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
const derived = useMemo(() => {
if (!stats) return null
const recentChatters = stats.recentPlayers.filter((player) => player.chats > 0)
const churn = stats.joinsToday > 0 ? Math.round((stats.leavesToday / stats.joinsToday) * 100) : 0
const stale = stats.activeLast15m > Math.max(stats.online + 3, stats.online * 2)
const busiestWorld = Object.entries(stats.byWorld).sort((a, b) => b[1] - a[1])[0]
return { recentChatters, churn, stale, busiestWorld }
}, [stats])
return (
<div className="pointer-events-auto fixed right-3 top-3 z-[90] flex max-h-[calc(100vh-24px)] w-[min(460px,calc(100vw-24px))] flex-col overflow-hidden rounded-3xl border border-amber-200/15 bg-[#07080d]/88 text-xs text-white shadow-[0_24px_80px_rgba(0,0,0,.62)] backdrop-blur-2xl">
<div className="border-b border-white/10 bg-gradient-to-r from-amber-300/10 via-cyan-300/8 to-violet-300/10 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-amber-200/25 bg-amber-200/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.18em] text-amber-100">Private</span>
<span className="text-[10px] uppercase tracking-[0.18em] text-white/45">Dashboard admin</span>
</div>
<div className="mt-1 text-base font-bold tracking-tight text-white">HermesWorld Control Room</div>
<div className="mt-0.5 text-[11px] text-white/50">Human relay analytics. NPC ambient chatter is client-side flavor and intentionally excluded.</div>
</div>
<div className="text-right text-[10px] text-white/45">
<div>Updated</div>
<div className="font-semibold text-white/70">{fmtTime(stats?.ts)}</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-4">
{error ? <div className="rounded-2xl border border-red-400/25 bg-red-500/10 p-3 text-red-100">{error}</div> : null}
{stats ? (
<>
<div className="grid grid-cols-3 gap-2">
<StatCard label="Online now" value={stats.online} accent="#34d399" />
<StatCard label="Unique today" value={stats.uniqueToday} accent="#fbbf24" />
<StatCard label="Peak today" value={stats.peakToday} accent="#a78bfa" />
<StatCard label="Active 15m" value={stats.activeLast15m} accent="#22d3ee" />
<StatCard label="Active 60m" value={stats.activeLast60m} accent="#60a5fa" />
<StatCard label="Human chats" value={stats.chatsToday} accent="#f472b6" />
</div>
<div className="grid grid-cols-3 gap-2">
<HealthPill label="Joins" value={stats.joinsToday} />
<HealthPill label="Leaves" value={stats.leavesToday} />
<HealthPill label="Churn" value={`${derived?.churn ?? 0}%`} warn={(derived?.churn ?? 0) > 75 && stats.joinsToday > 5} />
</div>
{derived?.stale ? (
<div className="rounded-2xl border border-yellow-300/25 bg-yellow-300/10 p-3 text-[11px] text-yellow-100">
Active players are much higher than live sockets. Likely reconnect/background-tab churn, not real concurrent users.
</div>
) : null}
<section>
<SectionTitle title="Worlds" detail={derived?.busiestWorld ? `Busiest: ${WORLD_LABELS[derived.busiestWorld[0]] ?? derived.busiestWorld[0]}` : 'No live world yet'} />
<div className="grid grid-cols-2 gap-2">
{Object.entries({ training: 0, agora: 0, forge: 0, grove: 0, oracle: 0, arena: 0, ...stats.byWorld }).map(([world, count]) => (
<div key={world} className="rounded-2xl border border-white/8 bg-white/[0.045] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-white/80">{WORLD_LABELS[world] ?? world}</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] text-white/65">{count}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-cyan-300/80" style={{ width: `${Math.min(100, count * 18)}%` }} />
</div>
</div>
))}
</div>
</section>
<section>
<SectionTitle title="Recent players" detail={`${stats.recentPlayers.length} tracked today`} />
<div className="max-h-56 space-y-1.5 overflow-auto rounded-2xl border border-white/8 bg-black/25 p-2">
{stats.recentPlayers.length === 0 ? <EmptyState label="No human players tracked yet." /> : null}
{stats.recentPlayers.slice(0, 14).map((player) => (
<div key={player.id} className="grid grid-cols-[1fr_auto] items-center gap-2 rounded-xl bg-white/[0.04] px-2.5 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ background: player.color || '#fff' }} />
<span className="truncate font-semibold" style={{ color: player.color || '#fff' }}>{player.name || player.id.slice(0, 8)}</span>
{player.chats > 0 ? <span className="rounded bg-cyan-300/12 px-1.5 py-0.5 text-[9px] uppercase tracking-[0.12em] text-cyan-100">chatter</span> : null}
</div>
<div className="mt-0.5 truncate text-[10px] text-white/45">
{WORLD_LABELS[player.lastWorld || ''] ?? player.lastWorld ?? 'unknown'} · {fmtAge(player.lastSeen)} · joined {player.joins}x · chats {player.chats}
</div>
</div>
<div className="text-right text-[10px] text-white/42">
<div>last chat</div>
<div className="text-white/65">{fmtAge(player.lastChatAt)}</div>
</div>
</div>
))}
</div>
</section>
<section>
<SectionTitle title="Recent human chatters" detail={`${derived?.recentChatters.length ?? 0} today`} />
<div className="flex flex-wrap gap-1.5 rounded-2xl border border-white/8 bg-white/[0.035] p-2">
{derived?.recentChatters.length === 0 ? <span className="text-[11px] text-white/40">No human chat yet. NPC bubbles are not counted here.</span> : null}
{derived?.recentChatters.slice(0, 12).map((player) => (
<span key={player.id} className="rounded-full border border-cyan-300/15 bg-cyan-300/8 px-2 py-1 text-[10px] text-cyan-50">
{player.name || player.id.slice(0, 8)} · {player.chats}
</span>
))}
</div>
</section>
<section>
<SectionTitle title="Recent events" detail="latest relay events" />
<div className="max-h-64 space-y-1.5 overflow-auto rounded-2xl border border-white/8 bg-black/25 p-2">
{stats.recentEvents.length === 0 ? <EmptyState label="No relay events yet." /> : null}
{stats.recentEvents.slice(0, 28).map((event, idx) => {
const style = EVENT_STYLES[event.type] ?? { label: event.type, tone: 'border-white/15 bg-white/8 text-white/80' }
return (
<div key={`${event.ts}-${event.id}-${idx}`} className="rounded-xl bg-white/[0.035] px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span className={`rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] ${style.tone}`}>{style.label}</span>
<span className="text-[10px] text-white/40">{fmtTime(event.ts)}</span>
</div>
<div className="mt-1 truncate text-[11px] text-white/70">
<span style={{ color: event.color || undefined }}>{event.name || event.id.slice(0, 8)}</span>
{event.world ? <span className="text-white/40"> · {WORLD_LABELS[event.world] ?? event.world}</span> : null}
{event.text ? <span className="text-white/55"> · {event.text}</span> : null}
</div>
</div>
)
})}
</div>
</section>
</>
) : null}
</div>
</div>
)
}
function SectionTitle({ title, detail }: { title: string; detail?: string }) {
return (
<div className="mb-1.5 flex items-end justify-between gap-2">
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-white/48">{title}</div>
{detail ? <div className="truncate text-[10px] text-white/35">{detail}</div> : null}
</div>
)
}
function StatCard({ label, value, accent }: { label: string; value: number; accent: string }) {
return (
<div className="rounded-2xl border border-white/8 bg-white/[0.045] px-3 py-2.5 shadow-inner shadow-white/[0.02]">
<div className="text-[9px] font-bold uppercase tracking-[0.13em] text-white/42">{label}</div>
<div className="mt-1 text-2xl font-black leading-none" style={{ color: accent }}>{value}</div>
</div>
)
}
function HealthPill({ label, value, warn = false }: { label: string; value: number | string; warn?: boolean }) {
return (
<div className={`rounded-2xl border px-3 py-2 ${warn ? 'border-yellow-300/25 bg-yellow-300/10' : 'border-white/8 bg-white/[0.04]'}`}>
<div className="text-[9px] uppercase tracking-[0.13em] text-white/42">{label}</div>
<div className="mt-0.5 text-sm font-bold text-white">{value}</div>
</div>
)
}
function EmptyState({ label }: { label: string }) {
return <div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-[11px] text-white/38">{label}</div>
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useWorkspaceStore } from '@/stores/workspace-store'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
import { botsFor } from '../lib/playground-bots'
@@ -21,10 +22,14 @@ type Props = {
export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, onToggle }: Props) {
const [draft, setDraft] = useState('')
const sidebarCollapsed = useWorkspaceStore((s) => s.sidebarCollapsed)
const chromeLeft = sidebarCollapsed ? 'min(120px, 9vw)' : '320px'
const chromeMaxWidth = sidebarCollapsed ? 'calc(100vw - 320px)' : 'calc(100vw - 520px)'
const [filter, setFilter] = useState<'all' | 'humans' | 'npcs'>('all')
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}, [messages.length])
}, [messages.length, filter])
// Live online count from the multiplayer hub (dispatched by playground-world-3d).
// Fallback: include bots so the chat doesn't say "0 online" while you're offline.
const [serverOnline, setServerOnline] = useState<number | null>(null)
@@ -53,6 +58,9 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
}, [])
const liveConnected = transport === 'ws' || transport === 'both'
const npcCount = botsFor(worldId).length
const humanMessages = messages.filter((m) => !(typeof m.authorId === 'string' && m.authorId.startsWith('bot:')))
const npcMessages = messages.filter((m) => typeof m.authorId === 'string' && m.authorId.startsWith('bot:'))
const visibleMessages = filter === 'humans' ? humanMessages : filter === 'npcs' ? npcMessages : messages
const onlineCount = serverOnline != null && liveConnected ? serverOnline : 1 + npcCount
const onlineLabel = serverOnline != null && liveConnected
? `${onlineCount} player${onlineCount === 1 ? '' : 's'}`
@@ -66,8 +74,8 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
: 'connecting'
return (
<div
className="pointer-events-auto fixed bottom-3 z-[60] flex max-w-[92vw] flex-col rounded-2xl border border-white/10 bg-black/65 text-white shadow-2xl backdrop-blur-xl"
style={{ width: 360, height: collapsed ? 42 : 240, maxWidth: 'calc(100vw - 320px)', left: 'min(120px, 9vw)' }}
className="pointer-events-auto fixed bottom-3 z-[60] flex max-w-[92vw] flex-col rounded-2xl border border-white/10 bg-black/70 text-white shadow-2xl backdrop-blur-xl"
style={{ width: 380, height: collapsed ? 42 : 264, maxWidth: chromeMaxWidth, left: chromeLeft }}
>
<div className="flex items-center justify-between border-b border-white/10 px-3 py-2">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-white/65">
@@ -77,7 +85,7 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
title={transportLabel}
/>
Chat · {onlineLabel}
{npcCount > 0 && <span className="text-white/35"> · {npcCount} NPC</span>}
{npcCount > 0 && <span className="text-white/35"> · {npcCount} ambient NPC</span>}
<span className="ml-1 rounded border border-white/15 bg-white/5 px-1.5 py-0.5 text-[8px] uppercase tracking-[0.14em] text-white/45">
{transportLabel}
</span>
@@ -91,17 +99,25 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
</div>
{!collapsed && (
<>
<div className="flex items-center gap-1 border-b border-white/8 px-2 py-1.5">
<FilterButton active={filter === 'all'} onClick={() => setFilter('all')} label="All" count={messages.length} />
<FilterButton active={filter === 'humans'} onClick={() => setFilter('humans')} label="Humans" count={humanMessages.length} />
<FilterButton active={filter === 'npcs'} onClick={() => setFilter('npcs')} label="NPC" count={npcMessages.length} />
<span className="ml-auto text-[9px] text-white/32">NPC flavor is local, not analytics</span>
</div>
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 text-[12px] leading-snug">
{messages.length === 0 ? (
<div className="text-center text-white/40">No messages yet say hi 👋</div>
{visibleMessages.length === 0 ? (
<div className="text-center text-white/40">
{filter === 'humans' ? 'No human chat yet — say hi 👋' : filter === 'npcs' ? 'No ambient NPC lines yet.' : 'No messages yet — say hi 👋'}
</div>
) : (
messages.map((m) => {
visibleMessages.map((m) => {
const isBot = typeof m.authorId === 'string' && m.authorId.startsWith('bot:')
return (
<div key={m.id} className="mb-1.5">
<div key={m.id} className={`mb-1.5 rounded-lg px-1.5 py-1 ${isBot ? 'bg-purple-300/[0.035] text-white/72' : 'bg-cyan-300/[0.045]'}`}>
{isBot && (
<span className="mr-1 rounded bg-purple-400/20 px-1 py-0.5 text-[8px] font-bold uppercase tracking-[0.14em] text-purple-200">
NPC
<span className="mr-1 rounded bg-purple-400/15 px-1 py-0.5 text-[8px] font-bold uppercase tracking-[0.14em] text-purple-200">
Ambient NPC
</span>
)}
<span className="font-semibold" style={{ color: m.color ?? 'white' }}>
@@ -126,7 +142,7 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
value={draft}
onChange={(e) => setDraft(e.target.value)}
maxLength={140}
placeholder="Press Enter to send…"
placeholder="Press Enter to send human chat…"
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-white/10 px-2 py-1 text-[12px] outline-none"
/>
<button
@@ -142,3 +158,19 @@ export function PlaygroundChat({ worldId, messages, onSend, collapsed = false, o
</div>
)
}
function FilterButton({ active, label, count, onClick }: { active: boolean; label: string; count: number; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-2 py-1 text-[9px] font-bold uppercase tracking-[0.12em] transition ${
active
? 'border-cyan-200/45 bg-cyan-200/15 text-cyan-50 shadow-[0_0_14px_rgba(34,211,238,.18)]'
: 'border-white/10 bg-white/[0.04] text-white/42 hover:bg-white/[0.08] hover:text-white/70'
}`}
>
{label} <span className="text-white/45">{count}</span>
</button>
)
}

View File

@@ -0,0 +1,113 @@
import { Component, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useGLTF } from '@react-three/drei'
import * as THREE from 'three'
class GlbErrorBoundary extends Component<
{ children: ReactNode; onError?: () => void },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch() {
this.props.onError?.()
}
render() {
if (this.state.failed) return null
return this.props.children
}
}
const probeCache = new Map<string, 'unknown' | 'present' | 'missing'>()
export function useGlbProbe(url: string): 'unknown' | 'present' | 'missing' {
const [state, setState] = useState<'unknown' | 'present' | 'missing'>(
() => probeCache.get(url) || 'unknown',
)
useEffect(() => {
if (probeCache.get(url) === 'present' || probeCache.get(url) === 'missing') {
setState(probeCache.get(url)!)
return
}
let cancelled = false
fetch(url, { method: 'HEAD' })
.then((r) => {
if (cancelled) return
const ct = r.headers.get('content-type') || ''
const isReal =
r.ok &&
!ct.includes('text/html') &&
(ct.includes('octet-stream') ||
ct.includes('gltf') ||
ct.includes('binary') ||
ct === '' ||
ct.includes('application/'))
const v = isReal ? 'present' : 'missing'
probeCache.set(url, v)
setState(v)
})
.catch(() => {
if (cancelled) return
probeCache.set(url, 'missing')
setState('missing')
})
return () => {
cancelled = true
}
}, [url])
return state
}
function GlbInner({ url, scale, yOffset }: { url: string; scale: number; yOffset: number }) {
const { scene } = useGLTF(url) as any
const ref = useRef<THREE.Group>(null)
const cloned = useMemo(() => {
const s = (scene as THREE.Object3D).clone(true)
s.traverse((obj: any) => {
if (obj.isMesh) {
obj.castShadow = true
obj.receiveShadow = false
obj.raycast = () => {}
if (obj.material && obj.material.map) {
obj.material.map.anisotropy = 4
}
}
})
return s
}, [scene])
return (
<group ref={ref} position={[0, yOffset, 0]} scale={scale}>
<primitive object={cloned} />
</group>
)
}
export function OptionalGlbBody({
url,
scale = 1,
yOffset = 0,
}: {
url: string
scale?: number
yOffset?: number
}) {
const status = useGlbProbe(url)
const [hardFailed, setHardFailed] = useState(false)
if (status !== 'present' || hardFailed) return null
return (
<GlbErrorBoundary
onError={() => {
probeCache.set(url, 'missing')
setHardFailed(true)
}}
>
<Suspense fallback={null}>
<GlbInner url={url} scale={scale} yOffset={yOffset} />
</Suspense>
</GlbErrorBoundary>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useWorkspaceStore } from '@/stores/workspace-store'
import type { PlaygroundWorldId } from '../lib/playground-rpg'
import type { PlaygroundRpgState, RewardToast } from '../hooks/use-playground-rpg'
@@ -58,6 +59,8 @@ export function PlaygroundHud({
objectiveTarget,
}: HudProps) {
const { playerProfile } = state
const sidebarCollapsed = useWorkspaceStore((s) => s.sidebarCollapsed)
const chromeLeft = sidebarCollapsed ? 'min(120px, 9vw)' : '320px'
// Compute heading angle from player to objective target (in degrees, screen up = 0).
// Throttled to ~10 Hz so we don't re-render the HUD on every animation frame.
@@ -84,10 +87,14 @@ export function PlaygroundHud({
<>
{/* Combined player card: avatar portrait + name + level + title + HP/MP/SP/XP */}
{/* Sits to the right of the side rail (left:140 instead of left:3) so it doesn't crowd the chat. */}
<div className="pointer-events-auto fixed top-3 z-[70] flex max-w-[360px] flex-col items-start gap-2" style={{ left: 'min(120px, 9vw)' }}>
<div className="pointer-events-auto fixed top-3 z-[70] flex max-w-[360px] flex-col items-start gap-2" style={{ left: chromeLeft }}>
<div
className="rounded-2xl border-2 border-white/15 bg-gradient-to-b from-[#0b1320]/92 to-black/86 px-3 py-2.5 text-white shadow-2xl backdrop-blur-xl"
style={{ boxShadow: `0 0 18px ${worldAccent}33, 0 12px 36px rgba(0,0,0,.55)` }}
className="rounded-3xl border px-3 py-2.5 text-white shadow-2xl backdrop-blur-xl"
style={{
borderColor: `${worldAccent}38`,
background: `linear-gradient(180deg, rgba(16,22,31,.92), rgba(3,7,18,.88)), radial-gradient(circle at 20% 0%, ${worldAccent}24, transparent 55%)`,
boxShadow: `0 0 24px ${worldAccent}30, inset 0 1px 0 rgba(255,255,255,.08), 0 14px 42px rgba(0,0,0,.58)`,
}}
>
<div className="flex items-center gap-3">
{/* Avatar portrait + level badge */}
@@ -142,12 +149,16 @@ export function PlaygroundHud({
{/* Current Objective — top-center banner with arrow pointing toward the objective */}
<div className="pointer-events-auto fixed left-1/2 top-3 z-[71] flex w-[min(92vw,460px)] -translate-x-1/2 flex-col items-center">
<div
className="flex w-full items-center gap-2 rounded-2xl border-2 border-white/15 bg-gradient-to-b from-[#0b1320]/92 to-black/86 px-3 py-2 text-white shadow-2xl backdrop-blur-xl"
style={{ boxShadow: `0 0 18px ${worldAccent}33, 0 12px 36px rgba(0,0,0,.55)` }}
className="flex w-full items-center gap-2 rounded-3xl border px-3 py-2 text-white shadow-2xl backdrop-blur-xl"
style={{
borderColor: `${worldAccent}42`,
background: `linear-gradient(180deg, rgba(16,22,31,.94), rgba(3,7,18,.9)), radial-gradient(circle at 0% 0%, ${worldAccent}22, transparent 55%)`,
boxShadow: `0 0 24px ${worldAccent}32, inset 0 1px 0 rgba(255,255,255,.08), 0 14px 42px rgba(0,0,0,.58)`,
}}
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border"
style={{ borderColor: `${worldAccent}55`, background: `${worldAccent}1a` }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border"
style={{ borderColor: `${worldAccent}65`, background: `linear-gradient(180deg, ${worldAccent}24, rgba(255,255,255,.04))`, boxShadow: `inset 0 1px 0 rgba(255,255,255,.08), 0 0 16px ${worldAccent}24` }}
title={arrowDeg != null ? 'Pointing toward objective' : 'Objective'}
>
<span

View File

@@ -1,128 +1,32 @@
/**
* Optional GLB-based NPC body.
*
* If `/avatars-3d/<id>.glb` exists, render it instead of the voxel body.
* Otherwise the parent renders nothing extra and the voxel mesh shows.
*
* Generation pipeline (manual, one-time):
* 1. Visit https://www.meshy.ai/ (or Tripo3D).
* 2. Prompt per Greek god, e.g. "Greek goddess Athena, helmet, robe,
* stylized low-poly, T-pose, full body, suitable for game character".
* 3. Download GLB, drop into `public/avatars-3d/athena.glb`.
* 4. Reload — character now has a real 3D body.
*
* Optimization notes:
* - useGLTF caches across instances (drei).
* - We freeze materials and disable raycasting on geometry to avoid
* pointer hit cost when the parent already handles clicks.
* Canonical path is now `/assets/hermesworld/characters/<id>.glb`.
* Legacy `/avatars-3d/<id>.glb` is still probed as a fallback.
*/
import { Component, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useGLTF } from '@react-three/drei'
import * as THREE from 'three'
class GlbErrorBoundary extends Component<
{ children: ReactNode; onError?: () => void },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError() { return { failed: true } }
componentDidCatch() { this.props.onError?.() }
render() {
if (this.state.failed) return null
return this.props.children
}
import { OptionalGlbBody } from './playground-glb-body'
type PlaygroundNpcGlbProps = {
avatar?: string
}
type Props = {
/** NPC id; will look for `/avatars-3d/<id>.glb`. */
npcId: string
/** Scale relative to voxel body (voxel body is ~1.6u tall). */
scale?: number
/** Y offset to align feet to ground. */
yOffset?: number
function candidateUrls(avatar?: string): string[] {
const id = (avatar || 'villager-common').trim()
const safe = id.replace(/[^a-z0-9_-]+/gi, '') || 'villager-common'
return [
`/assets/hermesworld/characters/${safe}.glb`,
`/avatars-3d/${safe}.glb`,
]
}
/**
* Module-level cache of probed URLs so we don't 404-spam on repeated
* mounts. Maps url -> 'unknown' | 'present' | 'missing'.
*/
const probeCache = new Map<string, 'unknown' | 'present' | 'missing'>()
function useGlbProbe(url: string): 'unknown' | 'present' | 'missing' {
const [state, setState] = useState<'unknown' | 'present' | 'missing'>(
() => probeCache.get(url) || 'unknown',
)
useEffect(() => {
if (probeCache.get(url) === 'present' || probeCache.get(url) === 'missing') {
setState(probeCache.get(url)!)
return
}
let cancelled = false
// Important: TanStack Start's catch-all returns 200 + text/html for
// missing static files. We must inspect Content-Type to know if a
// real GLB exists, otherwise useGLTF will try to parse HTML and crash.
fetch(url, { method: 'HEAD' })
.then((r) => {
if (cancelled) return
const ct = r.headers.get('content-type') || ''
const isReal = r.ok
&& !ct.includes('text/html')
&& (ct.includes('octet-stream') || ct.includes('gltf') || ct.includes('binary') || ct === '' || ct.includes('application/'))
const v = isReal ? 'present' : 'missing'
probeCache.set(url, v)
setState(v)
})
.catch(() => {
if (cancelled) return
probeCache.set(url, 'missing')
setState('missing')
})
return () => { cancelled = true }
}, [url])
return state
}
function GlbInner({ url, scale, yOffset }: { url: string; scale: number; yOffset: number }) {
const { scene } = useGLTF(url) as any
const ref = useRef<THREE.Group>(null)
const cloned = useMemo(() => {
const s = (scene as THREE.Object3D).clone(true)
s.traverse((obj: any) => {
if (obj.isMesh) {
obj.castShadow = true
obj.receiveShadow = false
obj.raycast = () => {} // parent handles click
if (obj.material && obj.material.map) {
obj.material.map.anisotropy = 4
}
}
})
return s
}, [scene])
export function PlaygroundNpcGlb({ avatar }: PlaygroundNpcGlbProps) {
const urls = candidateUrls(avatar)
return (
<group ref={ref} position={[0, yOffset, 0]} scale={scale}>
<primitive object={cloned} />
</group>
)
}
/**
* Renders <PlaygroundNpcGlb> only when the GLB is actually present.
* Returns null otherwise so the parent's voxel body shows.
*/
export function PlaygroundNpcGlb({ npcId, scale = 1, yOffset = 0 }: Props) {
const url = `/avatars-3d/${npcId}.glb`
const status = useGlbProbe(url)
const [hardFailed, setHardFailed] = useState(false)
if (status !== 'present' || hardFailed) return null
return (
<GlbErrorBoundary onError={() => {
probeCache.set(url, 'missing')
setHardFailed(true)
}}>
<Suspense fallback={null}>
<GlbInner url={url} scale={scale} yOffset={yOffset} />
</Suspense>
</GlbErrorBoundary>
<>
{urls.map((url) => (
<OptionalGlbBody key={url} url={url} scale={0.95} yOffset={0} />
))}
</>
)
}

View File

@@ -0,0 +1,26 @@
import { OptionalGlbBody } from './playground-glb-body'
type PlaygroundPlayerGlbProps = {
avatarId?: string
}
function candidateUrls(avatarId?: string): string[] {
const id = (avatarId || 'player-adventurer').trim()
const safe = id.replace(/[^a-z0-9_-]+/gi, '') || 'player-adventurer'
return [
`/assets/hermesworld/characters/${safe}.glb`,
'/assets/hermesworld/characters/player-adventurer.glb',
`/avatars-3d/${safe}.glb`,
]
}
export function PlaygroundPlayerGlb({ avatarId }: PlaygroundPlayerGlbProps) {
const urls = candidateUrls(avatarId)
return (
<>
{urls.map((url) => (
<OptionalGlbBody key={url} url={url} scale={0.92} yOffset={0} />
))}
</>
)
}

View File

@@ -90,73 +90,73 @@ const WORLDS_3D: Record<PlaygroundWorldId, WorldDef> = {
id: 'training',
name: 'Training Grounds',
accent: '#5eead4',
groundColor: '#16362d',
skyColor: '#07131a',
ambient: '#183d34',
groundColor: '#1c4a3b',
skyColor: '#0b2530',
ambient: '#2a6b59',
pillarColor: '#99f6e4',
pillarType: 'training',
fogNear: 20,
fogFar: 60,
fogNear: 16,
fogFar: 54,
},
agora: {
id: 'agora',
name: 'The Agora',
accent: '#d9b35f',
groundColor: '#5a8a4f',
skyColor: '#cfe7f0',
ambient: '#a8c8d8',
groundColor: '#6b9a55',
skyColor: '#b9e2ef',
ambient: '#d7be88',
pillarColor: '#f3dcb0',
pillarType: 'classical',
fogNear: 22,
fogFar: 70,
fogNear: 18,
fogFar: 62,
},
forge: {
id: 'forge',
name: 'The Forge',
accent: '#22d3ee',
groundColor: '#181e2e',
skyColor: '#060712',
ambient: '#1a2540',
pillarColor: '#2dd4bf',
groundColor: '#21192a',
skyColor: '#090611',
ambient: '#45211c',
pillarColor: '#ff8a3d',
pillarType: 'tech',
fogNear: 14,
fogFar: 48,
fogNear: 10,
fogFar: 42,
},
grove: {
id: 'grove',
name: 'The Grove',
accent: '#34d399',
groundColor: '#1a3a25',
skyColor: '#06150f',
ambient: '#1a4030',
groundColor: '#193b2c',
skyColor: '#071811',
ambient: '#1f5f48',
pillarColor: '#86efac',
pillarType: 'forest',
fogNear: 16,
fogFar: 50,
fogNear: 11,
fogFar: 44,
},
oracle: {
id: 'oracle',
name: 'Oracle Temple',
accent: '#a78bfa',
groundColor: '#231b3a',
skyColor: '#080714',
ambient: '#251c40',
groundColor: '#261b46',
skyColor: '#0b0718',
ambient: '#33215e',
pillarColor: '#c4b5fd',
pillarType: 'temple',
fogNear: 16,
fogFar: 50,
fogNear: 12,
fogFar: 46,
},
arena: {
id: 'arena',
name: 'Benchmark Arena',
accent: '#fb7185',
groundColor: '#3a1820',
skyColor: '#16070a',
ambient: '#3a1822',
groundColor: '#491827',
skyColor: '#1b070c',
ambient: '#55202c',
pillarColor: '#fda4af',
pillarType: 'arena',
fogNear: 14,
fogFar: 42,
fogNear: 10,
fogFar: 38,
},
}
@@ -398,8 +398,7 @@ function TechPillars({ world }: { world: WorldDef }) {
<ringGeometry args={[4, 4.4, 64]} />
<meshStandardMaterial color={world.accent} emissive={world.accent} emissiveIntensity={1} />
</mesh>
{/* Hermes statue at the Forge center — cyan-tinted */}
<HermesStatue position={[0, 0, 0]} accent={world.accent} base="#1e293b" />
<ForgeSuperFurnace position={[0, 0, 0]} accent={world.accent} ember="#ff8a3d" />
{/* Cyan-flame Forge braziers */}
{[[-5, -5], [5, -5], [-5, 5], [5, 5]].map(([x, z], i) => (
<Brazier key={i} position={[x, 0, z]} color={world.accent} />
@@ -453,6 +452,7 @@ function ForestDecor({ world }: { world: WorldDef }) {
<ringGeometry args={[3, 4, 64]} />
<meshStandardMaterial color={world.accent} emissive={world.accent} emissiveIntensity={0.4} />
</mesh>
<MemoryTree position={[0, 0, 0]} accent={world.accent} />
</>
)
}
@@ -490,8 +490,7 @@ function TempleDecor({ world }: { world: WorldDef }) {
<ringGeometry args={[3.2, 3.6, 64]} />
<meshStandardMaterial color={world.accent} emissive={world.accent} emissiveIntensity={1} />
</mesh>
{/* Hermes statue at the temple center */}
<HermesStatue position={[0, 0, 0]} accent={world.accent} base="#312e81" />
<OracleRingTower position={[0, 0, 0]} accent={world.accent} />
{/* Mystical incense braziers */}
<Brazier position={[-4, 0, 0]} color="#a78bfa" />
<Brazier position={[4, 0, 0]} color="#a78bfa" />
@@ -503,6 +502,72 @@ function TempleDecor({ world }: { world: WorldDef }) {
)
}
function ForgeSuperFurnace({ position, accent, ember }: { position: [number, number, number]; accent: string; ember: string }) {
const ringRef = useRef<THREE.Group>(null)
useFrame(({ clock }) => {
if (ringRef.current) ringRef.current.rotation.y = clock.getElapsedTime() * 0.35
})
return (
<group position={position}>
<mesh castShadow receiveShadow position={[0, 0.35, 0]}>
<cylinderGeometry args={[2.1, 2.6, 0.7, 16]} />
<meshStandardMaterial color="#1f2937" roughness={0.45} metalness={0.25} emissive={ember} emissiveIntensity={0.12} />
</mesh>
<mesh castShadow position={[0, 1.45, 0]}>
<cylinderGeometry args={[1.15, 1.45, 2.2, 12]} />
<meshStandardMaterial color="#0f172a" roughness={0.38} metalness={0.35} emissive={accent} emissiveIntensity={0.38} />
</mesh>
<mesh position={[0, 2.7, 0]}>
<coneGeometry args={[1.4, 1.25, 8]} />
<meshStandardMaterial color="#2a1420" roughness={0.42} emissive={ember} emissiveIntensity={0.55} />
</mesh>
<group ref={ringRef} position={[0, 2.05, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}><torusGeometry args={[2.05, 0.045, 10, 64]} /><meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={1.25} /></mesh>
<mesh rotation={[Math.PI / 2, 0, Math.PI / 3]}><torusGeometry args={[1.45, 0.035, 10, 64]} /><meshStandardMaterial color={ember} emissive={ember} emissiveIntensity={1.15} /></mesh>
</group>
{[-1.4, 1.4].map((x) => <mesh key={x} castShadow position={[x, 0.85, 0]}><boxGeometry args={[0.45, 1.25, 0.55]} /><meshStandardMaterial color="#111827" emissive={ember} emissiveIntensity={0.35} /></mesh>)}
{[0, Math.PI / 2, Math.PI, -Math.PI / 2].map((a, i) => <mesh key={i} position={[Math.cos(a) * 2.8, 0.04, Math.sin(a) * 2.8]} rotation={[-Math.PI / 2, 0, a]}><planeGeometry args={[0.36, 3.4]} /><meshStandardMaterial color={ember} emissive={ember} emissiveIntensity={0.75} transparent opacity={0.55} /></mesh>)}
<pointLight position={[0, 2.1, 0]} color={ember} intensity={2.6} distance={13} />
<pointLight position={[0, 2.8, 0]} color={accent} intensity={1.8} distance={12} />
</group>
)
}
function MemoryTree({ position, accent }: { position: [number, number, number]; accent: string }) {
return (
<group position={position}>
<mesh castShadow position={[0, 1.45, 0]}>
<cylinderGeometry args={[0.42, 0.7, 2.9, 10]} />
<meshStandardMaterial color="#5b3a1f" roughness={0.82} emissive={accent} emissiveIntensity={0.08} />
</mesh>
{[0, 0.8, -0.85, 1.7, -1.7].map((x, i) => <mesh key={i} castShadow position={[x, 3.0 + (i % 2) * 0.25, i === 0 ? 0 : x * 0.18]}><dodecahedronGeometry args={[1.05 - Math.min(i, 2) * 0.12, 0]} /><meshStandardMaterial color={i === 0 ? '#86efac' : '#2bbf6f'} roughness={0.72} emissive={accent} emissiveIntensity={0.12} flatShading /></mesh>)}
<mesh position={[0, 4.3, 0]}><octahedronGeometry args={[0.34, 0]} /><meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={1.4} transparent opacity={0.9} /></mesh>
{[0, Math.PI / 2, Math.PI, -Math.PI / 2].map((a, i) => <mesh key={i} position={[Math.cos(a) * 2.9, 0.05, Math.sin(a) * 2.9]} rotation={[-Math.PI / 2, 0, 0]}><circleGeometry args={[0.22, 18]} /><meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={0.55} transparent opacity={0.55} /></mesh>)}
<pointLight position={[0, 3.6, 0]} color={accent} intensity={1.6} distance={12} />
</group>
)
}
function OracleRingTower({ position, accent }: { position: [number, number, number]; accent: string }) {
const ref = useRef<THREE.Group>(null)
useFrame(({ clock }) => {
if (ref.current) ref.current.rotation.y = clock.getElapsedTime() * 0.22
})
return (
<group position={position}>
<mesh castShadow receiveShadow position={[0, 0.28, 0]}><cylinderGeometry args={[1.9, 2.3, 0.56, 18]} /><meshStandardMaterial color="#312e81" roughness={0.5} emissive={accent} emissiveIntensity={0.18} /></mesh>
<mesh castShadow position={[0, 1.55, 0]}><cylinderGeometry args={[0.42, 0.58, 2.4, 12]} /><meshStandardMaterial color="#171032" roughness={0.42} emissive={accent} emissiveIntensity={0.35} /></mesh>
<group ref={ref} position={[0, 3.0, 0]}>
<mesh rotation={[Math.PI / 2, 0, 0]}><torusGeometry args={[2.2, 0.045, 12, 72]} /><meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={1.35} /></mesh>
<mesh rotation={[0.75, 0, 0]}><torusGeometry args={[1.55, 0.04, 12, 72]} /><meshStandardMaterial color="#c4b5fd" emissive="#c4b5fd" emissiveIntensity={1.2} /></mesh>
<mesh rotation={[0, 0, 0.75]}><torusGeometry args={[1.0, 0.035, 12, 64]} /><meshStandardMaterial color="#fef3c7" emissive="#fef3c7" emissiveIntensity={0.95} /></mesh>
<mesh><octahedronGeometry args={[0.42, 0]} /><meshStandardMaterial color={accent} emissive={accent} emissiveIntensity={1.8} transparent opacity={0.9} /></mesh>
</group>
<pointLight position={[0, 3, 0]} color={accent} intensity={2.1} distance={13} />
</group>
)
}
function FloatCrystal({ position, scale, color }: { position: [number, number, number]; scale: number; color: string }) {
const ref = useRef<THREE.Mesh>(null)
const phase = useMemo(() => Math.random() * Math.PI * 2, [])
@@ -547,6 +612,11 @@ function ArenaDecor({ world }: { world: WorldDef }) {
<ringGeometry args={[2.4, 4.4, 64]} />
<meshStandardMaterial color={world.accent} emissive={world.accent} emissiveIntensity={0.6} />
</mesh>
<mesh position={[0, 0.07, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[2.15, 48]} />
<meshStandardMaterial color="#16070a" emissive={world.accent} emissiveIntensity={0.22} roughness={0.45} />
</mesh>
<pointLight position={[0, 4, 0]} color={world.accent} intensity={2.2} distance={14} />
{/* scoreboard pillars */}
{[-7, 7].map((x, i) => (
<group key={i} position={[x, 0, 0]}>
@@ -586,6 +656,12 @@ function TrainingDecor({ world }: { world: WorldDef }) {
return (
<>
{/* Premium readable routes: the world should pull the eye toward the next stop, not rely only on HUD text. */}
<PathRibbon from={[0, 0]} to={[-11, 8]} color="#5eead4" width={1.25} />
<PathRibbon from={[0, 0]} to={[-5, -4]} color="#fb7185" width={1.2} />
<PathRibbon from={[0, 0]} to={[6, 0]} color="#a78bfa" width={1.08} />
<PathRibbon from={[0, 0]} to={[14, -10]} color="#22d3ee" width={1.35} />
<PathRibbon from={[-5, -4]} to={[-14, -10]} color="#fbbf24" width={1.08} />
{/* Hermes statue at the heart of the grounds */}
<HermesStatue position={[0, 0, 0]} accent={world.accent} />
{/* Practice dummies + weapon racks around the trainers ring */}
@@ -636,16 +712,31 @@ function TrainingDecor({ world }: { world: WorldDef }) {
</mesh>
</group>
<group position={[14, 0, -10]}>
<mesh position={[0, 0.04, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<ringGeometry args={[2.8, 4.6, 72]} />
<meshStandardMaterial color="#22d3ee" emissive="#22d3ee" emissiveIntensity={0.28} transparent opacity={0.48} />
</mesh>
<mesh castShadow position={[0, 2.3, 0]}>
<torusGeometry args={[2.2, 0.18, 18, 64]} />
<meshStandardMaterial color="#22d3ee" emissive="#22d3ee" emissiveIntensity={1.4} />
<meshStandardMaterial color="#22d3ee" emissive="#22d3ee" emissiveIntensity={1.75} />
</mesh>
<mesh castShadow position={[0, 2.3, -0.08]} rotation={[0, 0, Math.PI / 4]}>
<torusGeometry args={[1.55, 0.06, 12, 48]} />
<meshStandardMaterial color="#fbbf24" emissive="#fbbf24" emissiveIntensity={1.1} transparent opacity={0.9} />
</mesh>
{[-2.8, 2.8].map((x) => (
<mesh key={x} castShadow position={[x, 1.5, 0]}>
<group key={x} position={[x, 0, 0]}>
<mesh castShadow position={[0, 1.5, 0]}>
<boxGeometry args={[0.7, 3, 0.7]} />
<meshStandardMaterial color="#0f172a" emissive="#22d3ee" emissiveIntensity={0.25} />
<meshStandardMaterial color="#0f172a" emissive="#22d3ee" emissiveIntensity={0.32} />
</mesh>
<mesh castShadow position={[0, 3.25, 0]}>
<coneGeometry args={[0.65, 0.85, 4]} />
<meshStandardMaterial color="#fbbf24" emissive="#fbbf24" emissiveIntensity={0.45} roughness={0.42} />
</mesh>
</group>
))}
<pointLight position={[0, 2.5, 0]} color="#22d3ee" intensity={2.4} distance={13} />
</group>
{labels.map((label) => (
<Html key={label.text} position={label.pos} center distanceFactor={12}>
@@ -658,6 +749,39 @@ function TrainingDecor({ world }: { world: WorldDef }) {
)
}
function PathRibbon({
from,
to,
color,
width = 1.1,
}: {
from: [number, number]
to: [number, number]
color: string
width?: number
}) {
const dx = to[0] - from[0]
const dz = to[1] - from[1]
const length = Math.sqrt(dx * dx + dz * dz)
const angle = Math.atan2(dx, dz)
return (
<group position={[from[0] + dx / 2, 0.018, from[1] + dz / 2]} rotation={[0, angle, 0]}>
<mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[width, length]} />
<meshStandardMaterial color="#80633d" roughness={0.92} transparent opacity={0.74} polygonOffset polygonOffsetFactor={-1} polygonOffsetUnits={-1} />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[-width / 2, 0.006, 0]}>
<planeGeometry args={[0.06, length]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.35} transparent opacity={0.72} />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[width / 2, 0.006, 0]}>
<planeGeometry args={[0.06, length]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.35} transparent opacity={0.72} />
</mesh>
</group>
)
}
/* ── Hermes statue — winged-sandals hero centerpiece for plazas ── */
function HermesStatue({
position = [0, 0, 0],
@@ -2701,13 +2825,13 @@ function Scene({
)}
<color attach="background" args={[world.skyColor]} />
<fog attach="fog" args={[world.skyColor, world.fogNear, world.fogFar]} />
<hemisphereLight intensity={0.55} color={'#fff4d6'} groundColor={world.id === 'agora' ? '#3f6b3a' : world.ambient} />
<ambientLight intensity={0.35} color={world.ambient} />
<hemisphereLight intensity={0.68} color={world.id === 'forge' ? '#ffd0a3' : '#fff4d6'} groundColor={world.id === 'agora' ? '#3f6b3a' : world.ambient} />
<ambientLight intensity={0.28} color={world.ambient} />
<directionalLight
castShadow
position={[14, 18, 8]}
intensity={1.8}
color={'#fff1cc'}
position={[14, 20, 8]}
intensity={2.15}
color={world.id === 'oracle' ? '#ece7ff' : world.id === 'forge' ? '#ffd3aa' : '#fff1cc'}
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-30}
shadow-camera-right={30}
@@ -2722,8 +2846,8 @@ function Scene({
<WorldDecor world={world} />
<ScatteredScenery worldId={worldId} />
{/* Ambient atmosphere particles — light-touch for performance */}
<Sparkles count={50} scale={[60, 8, 60]} size={2.5} speed={0.22} color={world.accent} opacity={0.55} />
<Sparkles count={20} scale={[30, 4, 30]} size={1.2} speed={0.5} color={'#ffffff'} opacity={0.3} />
<Sparkles count={70} scale={[64, 10, 64]} size={2.8} speed={0.18} color={world.accent} opacity={0.62} />
<Sparkles count={24} scale={[34, 5, 34]} size={1.2} speed={0.45} color={'#ffffff'} opacity={0.25} />
{/* NPCs per world */}
{worldId === 'training' && (

View File

@@ -0,0 +1,447 @@
import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
const HERMES_REPO_URL = 'https://github.com/outsourc-e/hermes-workspace'
const HERMES_ROADMAP_URL = 'https://github.com/outsourc-e/hermes-workspace/blob/main/docs/hermesworld/master-roadmap.md'
const HERMES_FEATURES_URL = 'https://github.com/outsourc-e/hermes-workspace/blob/main/FEATURES-INVENTORY.md'
const externalLinkProps = { target: '_blank', rel: 'noreferrer' }
const capabilities = [
{ label: 'Persistent World', copy: 'World state keeps moving while agents continue work.', icon: '✦' },
{ label: 'Live Agents', copy: 'Companions follow, plan, craft, scout, and report back.', icon: '◈' },
{ label: 'Zones & Quests', copy: 'Each zone gives humans and agents a place to act.', icon: '◇' },
{ label: 'Memory Progression', copy: 'Agents carry context, unlocks, and completed history.', icon: '◎' },
{ label: 'Hermes Sigils', copy: 'Make invisible agent progress visible and collectible.', icon: '⚚' },
{ label: 'Multiplayer Presence', copy: 'Humans and agents share the same world layer.', icon: '◌' },
]
const zones = [
{
name: 'Training Grounds',
label: 'Starter Zone',
tone: '#76d88f',
copy: 'Learn the verbs of the world. Move, talk, equip, travel, and send your first companion on a quest.',
},
{
name: 'Forge',
label: 'Progression',
tone: '#ff9f55',
copy: 'Craft tools for agents. Upgrade companions, shape items, and turn raw progress into better workflows.',
},
{
name: 'Agora',
label: 'Social Hub',
tone: '#d9b35f',
copy: 'The social relay. Meet NPCs, inspect public quests, and watch live activity from humans and agents.',
},
{
name: 'Grove',
label: 'Memory Zone',
tone: '#34d399',
copy: 'A quieter zone for long-term memory, reflection, archived quests, and restoring agent energy.',
},
{
name: 'Oracle',
label: 'Planning',
tone: '#a78bfa',
copy: 'Planning and prophecy. Decompose goals, reveal quest paths, and route work to the right agent.',
},
{
name: 'Arena',
label: 'Combat Online',
tone: '#f87171',
copy: 'Battle, evals, and trials. Test agents in controlled challenges and unlock capabilities through risk.',
},
]
const party = [
{ name: 'Atlas', role: 'Scout', state: 'Following', tone: '#76d88f' },
{ name: 'Forge', role: 'Builder', state: 'Crafting', tone: '#ff9f55' },
{ name: 'Oracle', role: 'Planner', state: 'Planning', tone: '#a78bfa' },
]
const consoleLines = [
'move_to("Agora")',
'talk_to("Quartermaster")',
'accept_quest("Northern Gate")',
'equip("Hermes Sigil")',
'send_agent("Oracle", "plan route")',
'complete_quest("First Step")',
]
const progression = [
['Unlocks', 'Open zones, panes, capabilities, and world systems.'],
['Agent Progression', 'Upgrade companion abilities, tools, loadouts, and memory depth.'],
['Quests', 'Convert goals into trackable work with receipts and history.'],
['Cosmetics & Lore', 'Customize player, companions, banners, and world profile.'],
]
const todayDrops = [
['Landing Page', 'A cinematic share surface for the HermesWorld reveal.'],
['Roadmap', 'The build path from preview world to persistent agent RPG.'],
['Feature List', 'Zones, companions, quests, sigils, multiplayer, and world systems.'],
['Graphics Sprint', 'Next pass: in-game world art, logo lockup, and launch visuals.'],
]
export function HermesWorldLanding() {
return (
<main className="min-h-screen overflow-hidden bg-[#03060a] text-[#f8f3e7] selection:bg-[#d9b35f] selection:text-[#07080d]">
<HermesBackdrop />
<Header />
<section className="relative mx-auto grid min-h-[calc(100vh-82px)] w-full max-w-[1560px] items-center gap-10 px-4 pb-12 pt-8 sm:px-6 lg:grid-cols-[0.78fr_1.22fr] lg:px-8 lg:pb-18 lg:pt-10">
<div className="relative z-10 max-w-2xl lg:pl-2">
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.24em] text-[#f8e4ac] shadow-[0_0_42px_rgba(217,179,95,.12)]">
<span className="h-1.5 w-1.5 rounded-full bg-cyan-200 shadow-[0_0_18px_rgba(34,211,238,.95)]" />
HermesWorld Preview // Persistent Agent World
</div>
<h1 className="max-w-[760px] text-balance font-serif text-[clamp(3.6rem,7.5vw,8.4rem)] leading-[0.82] tracking-[-0.075em] text-[#fff6df] drop-shadow-[0_20px_80px_rgba(0,0,0,.65)]">
Your AI workspace is becoming a world.
</h1>
<p className="mt-7 max-w-xl text-pretty text-lg leading-8 text-[#d7d0bd]/72 sm:text-xl">
HermesWorld turns agents into companions inside a persistent world. Explore zones, complete quests, collect sigils, and watch agents keep working while the world keeps moving.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="/playground" className="group inline-flex items-center justify-center rounded-xl border border-[#ffe7a3]/50 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#11100b] shadow-[0_24px_80px_rgba(217,179,95,.28)] transition hover:-translate-y-0.5 hover:brightness-110">
Play Now <span className="ml-2 transition group-hover:translate-x-1"></span>
</a>
<a href={HERMES_REPO_URL} {...externalLinkProps} className="inline-flex items-center justify-center rounded-xl border border-[#d9b35f]/24 bg-[#0b1118]/78 px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#f8e4ac] shadow-[inset_0_1px_0_rgba(255,255,255,.08)] backdrop-blur-xl transition hover:border-[#d9b35f]/50 hover:bg-[#121823]">
View on GitHub
</a>
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="inline-flex items-center justify-center rounded-xl border border-[#d9b35f]/24 bg-[#0b1118]/78 px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#f8e4ac] shadow-[inset_0_1px_0_rgba(255,255,255,.08)] backdrop-blur-xl transition hover:border-[#d9b35f]/50 hover:bg-[#121823]">
Read Roadmap
</a>
</div>
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[11px] font-bold uppercase tracking-[0.16em] text-[#bfb49a]/60">
<span>Preview build</span>
<span className="text-[#d9b35f]/55"></span>
<span>Persistent agents</span>
<span className="text-[#d9b35f]/55"></span>
<span>Zones, quests, sigils</span>
</div>
<div className="mt-7 max-w-xl rounded-2xl border border-[#d9b35f]/18 bg-[#05080e]/70 p-4 shadow-[0_20px_70px_rgba(0,0,0,.28),inset_0_1px_0_rgba(255,255,255,.06)] backdrop-blur-xl">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-[#d9b35f]/35 bg-[#d9b35f]/12 text-lg shadow-[0_0_28px_rgba(217,179,95,.16)]">🏆</span>
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/70">Dropping today</div>
<div className="mt-1 text-sm font-bold leading-5 text-[#fff6df]">HermesWorld landing page, roadmap, feature list, and the first public build notes.</div>
</div>
</div>
</div>
</div>
<HeroWorldFrame />
</section>
<CapabilityStrip />
<TodayDropSection />
<ZonesSection />
<AgentsSection />
<SigilsSection />
<FinalCta />
<Footer />
</main>
)
}
function Header() {
return (
<header className="relative z-30 mx-auto mt-4 flex max-w-[1560px] items-center justify-between border-b border-[#d9b35f]/20 px-4 pb-4 sm:px-6 lg:px-8">
<a href="/hermes-world" className="flex items-center gap-3">
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-10 w-10 rounded-2xl shadow-[0_0_34px_rgba(34,211,238,.18)]" />
<div>
<div className="font-serif text-lg font-bold tracking-[-0.03em] text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></div>
<div className="text-[9px] font-black uppercase tracking-[0.22em] text-[#bfb49a]/46">Persistent Agent RPG</div>
</div>
</a>
<nav className="hidden items-center gap-8 text-[11px] font-black uppercase tracking-[0.18em] text-[#d7d0bd]/58 md:flex">
<a href="#world" className="transition hover:text-[#f8e4ac]">World</a>
<a href="#agents" className="transition hover:text-[#f8e4ac]">Agents</a>
<a href="#sigils" className="transition hover:text-[#f8e4ac]">Sigils</a>
<a href="#preview" className="transition hover:text-[#f8e4ac]">Preview</a>
<a href="#today" className="transition hover:text-[#f8e4ac]">Today</a>
<a href="/playground" className="rounded-lg border border-[#ffe7a3]/55 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-4 py-2 font-black text-[#11100b] shadow-[0_0_30px_rgba(217,179,95,.18)] transition hover:brightness-110"> Play</a>
<a href={HERMES_REPO_URL} {...externalLinkProps} className="rounded-lg border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-4 py-2 text-[#f8e4ac] shadow-[0_0_30px_rgba(217,179,95,.08)] transition hover:bg-[#d9b35f]/18">GitHub</a>
</nav>
</header>
)
}
function HeroWorldFrame() {
return (
<div id="preview" className="relative z-10 mx-auto w-full max-w-[930px]">
<div className="absolute -inset-10 rounded-[4rem] bg-[radial-gradient(circle_at_50%_40%,rgba(34,211,238,.18),transparent_52%),radial-gradient(circle_at_68%_72%,rgba(167,139,250,.16),transparent_48%)] blur-3xl" />
<div className="relative overflow-hidden rounded-[1.65rem] border border-[#d9b35f]/34 bg-[#05080e] p-2 shadow-[0_50px_160px_rgba(0,0,0,.72),0_0_80px_rgba(217,179,95,.12)]">
<div className="flex items-center justify-between border-b border-[#d9b35f]/18 px-3 py-2">
<div className="flex items-center gap-2">
<i className="h-2.5 w-2.5 rounded-full bg-red-400" />
<i className="h-2.5 w-2.5 rounded-full bg-amber-300" />
<i className="h-2.5 w-2.5 rounded-full bg-emerald-300" />
<span className="ml-3 text-[9px] font-black uppercase tracking-[0.24em] text-[#f8e4ac]/70">Live World Preview</span>
</div>
<div className="hidden items-center gap-2 text-[9px] font-black uppercase tracking-[0.18em] text-emerald-200/75 sm:flex">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(52,211,153,.9)]" />
Live World Build
</div>
</div>
<div className="relative aspect-[16/10] overflow-hidden rounded-[1.15rem] bg-[#0a1117]">
<div className="absolute inset-0">
<PlaygroundHeroCanvas />
</div>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_42%,transparent_36%,rgba(3,6,10,.28)_78%),linear-gradient(180deg,rgba(3,6,10,.02),rgba(3,6,10,.62))]" />
<div className="absolute left-4 top-4 rounded-xl border border-[#d9b35f]/24 bg-[#05080e]/70 px-3 py-2 shadow-2xl backdrop-blur-xl">
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-[#f8e4ac]/70">HermesWorld Entry</div>
<div className="mt-1 text-xs text-[#d7d0bd]/70">Portal online · Zones awakening · Sigils active</div>
</div>
<div className="absolute bottom-4 left-4 max-w-[320px] rounded-xl border border-cyan-200/20 bg-[#05080e]/72 p-3 shadow-2xl backdrop-blur-xl">
<div className="text-[9px] font-black uppercase tracking-[0.18em] text-cyan-100/72">World State</div>
<div className="mt-1 text-sm font-bold text-[#fff6df]">Agents need a world they can inhabit.</div>
<div className="mt-1 text-xs leading-5 text-[#d7d0bd]/58">Persistent zones, believable characters, cleaner quests, stronger atmosphere.</div>
</div>
<div className="absolute bottom-4 right-4 hidden rounded-xl border border-violet-200/20 bg-[#05080e]/70 p-3 text-xs text-[#d7d0bd]/62 shadow-2xl backdrop-blur-xl sm:block">
<div className="text-[9px] font-black uppercase tracking-[0.18em] text-violet-100/72">Launch Focus</div>
<div className="mt-1">Agora believable</div>
<div>Character pipeline</div>
<div>Graphics + HUD pass</div>
</div>
</div>
</div>
</div>
)
}
function CapabilityStrip() {
return (
<section className="relative mx-auto max-w-[1560px] px-4 py-5 sm:px-6 lg:px-8">
<div className="grid overflow-hidden rounded-2xl border border-[#d9b35f]/20 bg-[#071018]/76 shadow-[inset_0_1px_0_rgba(255,255,255,.06),0_30px_100px_rgba(0,0,0,.38)] backdrop-blur-xl sm:grid-cols-2 lg:grid-cols-6">
{capabilities.map((capability) => (
<div key={capability.label} className="border-b border-r border-[#d9b35f]/12 p-5 last:border-r-0 sm:min-h-36 lg:border-b-0">
<div className="mb-3 text-2xl text-[#d9b35f] drop-shadow-[0_0_18px_rgba(217,179,95,.3)]">{capability.icon}</div>
<div className="text-[11px] font-black uppercase tracking-[0.17em] text-[#f8e4ac]">{capability.label}</div>
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/52">{capability.copy}</p>
</div>
))}
</div>
</section>
)
}
function TodayDropSection() {
return (
<section id="today" className="relative mx-auto max-w-[1560px] px-4 py-16 sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-[2rem] border border-[#d9b35f]/24 bg-[radial-gradient(circle_at_18%_0%,rgba(217,179,95,.2),transparent_34%),linear-gradient(135deg,rgba(7,16,24,.92),rgba(4,7,12,.9))] p-5 shadow-[0_34px_120px_rgba(0,0,0,.42)] sm:p-7 lg:p-8">
<div className="grid gap-8 lg:grid-cols-[0.74fr_1.26fr] lg:items-center">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/24 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.22em] text-[#f8e4ac]">
<span>🏆</span>
Launch drop
</div>
<h2 className="mt-4 font-serif text-4xl font-bold leading-[0.92] tracking-[-0.055em] text-[#fff6df] sm:text-5xl lg:text-6xl">
Dropping HermesWorld today.
</h2>
<p className="mt-4 max-w-xl text-sm leading-7 text-[#d7d0bd]/62 sm:text-base">
Landing page, roadmap, feature list, and build notes first. Then we move straight into the game graphics sprint: world art, logo lockup, social cards, and clip-ready visuals.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{todayDrops.map(([title, copy]) => (
<article key={title} className="rounded-2xl border border-white/10 bg-black/22 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,.06)]">
<div className="mb-3 h-px w-12 bg-[linear-gradient(90deg,#d9b35f,transparent)]" />
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-[#f8e4ac]">{title}</h3>
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/54">{copy}</p>
</article>
))}
</div>
</div>
</div>
</section>
)
}
function ZonesSection() {
return (
<section id="world" className="relative mx-auto max-w-[1560px] px-4 py-20 sm:px-6 lg:px-8">
<div className="text-center">
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[#d9b35f]/70">The world map</div>
<h2 className="mx-auto mt-3 max-w-4xl font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Six zones. One persistent agent world.</h2>
<p className="mx-auto mt-4 max-w-2xl text-sm leading-7 text-[#d7d0bd]/58 sm:text-base">Every zone teaches a different part of the agent loop: training, crafting, strategy, memory, prophecy, and evaluation.</p>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
{zones.map((zone, index) => (
<article key={zone.name} className="group overflow-hidden rounded-2xl border border-[#d9b35f]/20 bg-[#071018]/82 p-2 shadow-[0_24px_90px_rgba(0,0,0,.38)] transition hover:-translate-y-1 hover:border-[#d9b35f]/38">
<div className="relative h-48 overflow-hidden rounded-xl bg-[#0c1415]" style={{ boxShadow: `inset 0 0 100px ${zone.tone}22` }}>
<ZoneDiorama index={index} tone={zone.tone} />
<div className="absolute left-3 top-3 rounded-md border border-white/10 bg-black/42 px-2 py-1 text-[8px] font-black uppercase tracking-[0.16em] text-white/65 backdrop-blur">{zone.label}</div>
</div>
<div className="p-3">
<h3 className="font-serif text-2xl font-bold tracking-[-0.035em]" style={{ color: zone.tone }}>{zone.name}</h3>
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/56">{zone.copy}</p>
</div>
</article>
))}
</div>
</section>
)
}
function AgentsSection() {
return (
<section id="agents" className="relative border-y border-[#d9b35f]/14 bg-[#061016]/72 px-4 py-20 sm:px-6 lg:px-8">
<div className="mx-auto grid max-w-[1440px] gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<div>
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-cyan-100/60">Humans + Agents</div>
<h2 className="mt-3 max-w-2xl font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Your agents live in the world with you.</h2>
<p className="mt-5 max-w-xl text-base leading-8 text-[#d7d0bd]/62">HermesWorld turns agents into visible companions. They can follow, take quests, report progress, and eventually move through the world on their own.</p>
<div className="mt-7 grid gap-3 text-sm text-[#d7d0bd]/62 sm:grid-cols-3">
<InfoPill title="Companions" copy="Roles, memory, and status." />
<InfoPill title="Takeover" copy="Future agent world actions." />
<InfoPill title="Offline" copy="Progress continues between visits." />
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[0.8fr_1fr]">
<div className="rounded-2xl border border-[#d9b35f]/18 bg-[#05080e]/78 p-4 shadow-2xl backdrop-blur-xl">
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-[#f8e4ac]/62">Your Party</div>
<div className="space-y-3">
{party.map((agent) => (
<div key={agent.name} className="rounded-xl border border-white/8 bg-white/[0.035] p-3">
<div className="flex items-center justify-between">
<div>
<div className="text-lg font-bold text-[#fff6df]">{agent.name}</div>
<div className="text-xs text-[#d7d0bd]/45">{agent.role}</div>
</div>
<div className="h-10 w-10 rounded-xl" style={{ background: `${agent.tone}22`, border: `1px solid ${agent.tone}66`, boxShadow: `0 0 22px ${agent.tone}22` }} />
</div>
<div className="mt-3 rounded-lg border border-white/8 bg-black/28 px-3 py-2 text-xs font-bold" style={{ color: agent.tone }}>{agent.state}</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-cyan-100/16 bg-[#04070c]/82 p-4 shadow-2xl backdrop-blur-xl">
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-cyan-100/62">Agent Console</div>
<div className="min-h-[340px] rounded-xl border border-white/8 bg-black/36 p-4 font-mono text-xs leading-7 text-[#bff9ff]/72">
{consoleLines.map((line) => (
<div key={line}><span className="text-[#d9b35f]"></span> {line}</div>
))}
<div className="mt-3 text-[#76d88f]">quest accepted: Open the Northern Gate</div>
<div className="text-[#a78bfa]">agent route planned: Oracle Grove Forge</div>
<div className="mt-3 animate-pulse text-[#f8e4ac]"></div>
</div>
</div>
</div>
</div>
</section>
)
}
function SigilsSection() {
return (
<section id="sigils" className="relative mx-auto max-w-[1560px] px-4 py-20 sm:px-6 lg:px-8">
<div className="grid gap-8 rounded-[2rem] border border-[#d9b35f]/22 bg-[radial-gradient(circle_at_32%_42%,rgba(217,179,95,.22),transparent_34%),#071018] p-5 shadow-[0_40px_140px_rgba(0,0,0,.45)] md:grid-cols-[0.9fr_1.1fr] md:p-8 lg:p-10">
<div className="relative flex min-h-[360px] items-center justify-center overflow-hidden rounded-[1.35rem] border border-[#d9b35f]/18 bg-[#04070c]">
<div className="absolute h-64 w-64 rounded-full bg-[#d9b35f]/20 blur-3xl" />
<div className="relative flex h-56 w-56 items-center justify-center rounded-full border border-[#d9b35f]/42 bg-[radial-gradient(circle,#f8e4ac_0%,#d9b35f_22%,#4b3516_68%,#120d08_100%)] shadow-[0_0_90px_rgba(217,179,95,.32)]">
<img src="/hermesworld-logo.svg" alt="Hermes Sigil" className="h-36 w-36 rounded-[2rem] shadow-[0_0_40px_rgba(34,211,238,.18)]" />
</div>
</div>
<div className="flex flex-col justify-center">
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[#d9b35f]/72">In-world progression</div>
<h2 className="mt-3 font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Collect Hermes Sigils as you unlock the world.</h2>
<p className="mt-5 text-base leading-8 text-[#d7d0bd]/62">Hermes Sigils are progression artifacts earned through quests, agent upgrades, world exploration, and system mastery. They make invisible agent progress visible.</p>
<div className="mt-7 grid gap-3 sm:grid-cols-2">
{progression.map(([title, copy]) => (
<div key={title} className="rounded-xl border border-[#d9b35f]/14 bg-black/20 p-4">
<div className="text-sm font-black uppercase tracking-[0.16em] text-[#f8e4ac]">{title}</div>
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/54">{copy}</p>
</div>
))}
</div>
</div>
</div>
</section>
)
}
function FinalCta() {
return (
<section className="relative overflow-hidden px-4 py-20 sm:px-6 lg:px-8">
<div className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,rgba(3,6,10,.18),#03060a),url('/hermesworld-world.png')] bg-cover bg-center opacity-45" />
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_20%,rgba(217,179,95,.2),transparent_42%),linear-gradient(90deg,#03060a_0%,rgba(3,6,10,.58)_50%,#03060a_100%)]" />
<div className="mx-auto max-w-[880px] rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/78 p-8 text-center shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-xl sm:p-12">
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-cyan-100/62">Enter the world</div>
<h2 className="mt-3 font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Build with agents in a world, not a chat box.</h2>
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-[#d7d0bd]/62">Enter HermesWorld and explore the first playable layer of Hermes Workspace: zones, quests, companions, sigils, and persistent agent progression.</p>
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
<a href="/playground" className="rounded-xl bg-[#f8e4ac] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#11100b] transition hover:-translate-y-0.5 hover:bg-white"> Play Now</a>
<a href={HERMES_REPO_URL} {...externalLinkProps} className="rounded-xl border border-white/12 bg-white/[0.055] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-white/78 transition hover:bg-white/[0.1]">View GitHub</a>
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="rounded-xl border border-white/12 bg-white/[0.055] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-white/78 transition hover:bg-white/[0.1]">Read Roadmap</a>
</div>
</div>
</section>
)
}
function Footer() {
return (
<footer className="mx-auto flex max-w-[1560px] flex-col gap-4 border-t border-[#d9b35f]/14 px-4 py-8 text-xs text-[#d7d0bd]/42 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8">
<div className="flex items-center gap-3">
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-8 w-8 rounded-xl" />
<span className="font-serif text-base text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></span>
</div>
<div className="flex flex-wrap gap-4 uppercase tracking-[0.16em]">
<a href={HERMES_REPO_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">GitHub</a>
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">Roadmap</a>
<a href={HERMES_FEATURES_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">Feature List</a>
<a href="#today" className="hover:text-[#f8e4ac]">Todays Drop</a>
</div>
</footer>
)
}
function InfoPill({ title, copy }: { title: string; copy: string }) {
return (
<div className="rounded-xl border border-[#d9b35f]/14 bg-[#05080e]/42 p-4">
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-[#f8e4ac]">{title}</div>
<div className="mt-2 text-xs leading-5 text-[#d7d0bd]/50">{copy}</div>
</div>
)
}
function ZoneDiorama({ tone, index }: { tone: string; index: number }) {
return (
<div className="relative h-full w-full overflow-hidden bg-[linear-gradient(180deg,rgba(255,255,255,.08),rgba(0,0,0,.22))]">
<div className="absolute inset-x-0 bottom-0 h-24 bg-[#1d392a]" />
<div className="absolute left-1/2 top-[64%] h-36 w-56 -translate-x-1/2 -translate-y-1/2 rotate-[-8deg] rounded-[42%] bg-[#31492f] shadow-[0_28px_60px_rgba(0,0,0,.48)]" />
<div className="absolute left-[18%] top-[58%] h-8 w-40 rotate-[-10deg] rounded-full bg-[#caa65c]/76" />
<div className="absolute left-[42%] top-[34%] h-20 w-20 rounded-full border-4" style={{ borderColor: tone, boxShadow: `0 0 34px ${tone}` }} />
<div className="absolute left-[43%] top-[47%] h-12 w-14 rounded bg-[#e8d2a4] shadow-lg"><div className="absolute -top-4 h-6 w-16 -translate-x-1 rounded-sm" style={{ background: index % 2 ? '#7f1d1d' : '#1d4d7f' }} /></div>
<div className="absolute left-[62%] top-[52%] h-9 w-9 rounded-full bg-[#f8e4ac] shadow-[0_0_28px_rgba(248,228,172,.35)]" />
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="absolute h-8 w-8 rounded-[34%] bg-[#246b44] shadow-lg" style={{ left: `${5 + ((i * 17 + index * 9) % 88)}%`, top: `${10 + ((i * 29 + index * 11) % 72)}%` }} />
))}
<div className="absolute inset-0" style={{ background: `radial-gradient(circle at 52% 42%, ${tone}20, transparent 42%)` }} />
</div>
)
}
function HermesBackdrop() {
return (
<div className="pointer-events-none fixed inset-0 -z-10">
<div className="absolute inset-0 bg-[linear-gradient(180deg,#071018_0%,#03060a_54%,#020305_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_14%_16%,rgba(217,179,95,.16),transparent_30%),radial-gradient(circle_at_72%_8%,rgba(34,211,238,.14),transparent_32%),radial-gradient(circle_at_84%_72%,rgba(167,139,250,.14),transparent_34%)]" />
<div className="absolute inset-0 opacity-[0.13] [background-image:linear-gradient(rgba(248,228,172,.12)_1px,transparent_1px),linear-gradient(90deg,rgba(248,228,172,.12)_1px,transparent_1px)] [background-size:72px_72px]" />
<div className="absolute inset-0 opacity-[0.22] [background-image:radial-gradient(circle_at_center,rgba(248,228,172,.45)_1px,transparent_1px)] [background-size:42px_42px]" />
</div>
)
}

View File

@@ -0,0 +1,110 @@
export type CharacterArchetypeId =
| 'player-adventurer'
| 'oracle-scholar'
| 'forge-blacksmith'
| 'guard-knight'
| 'merchant-villager'
| 'villager-common'
export type CharacterAnimationClip =
| 'idle'
| 'walk'
| 'run'
| 'talk'
| 'inspect'
| 'use'
export type CharacterArchetype = {
id: CharacterArchetypeId
label: string
zone: 'agora' | 'oracle' | 'forge' | 'grove' | 'arena' | 'training-grounds'
role: 'player' | 'npc'
modelPath: string
defaultScale: number
paletteHint: string
notes: string
}
export const CHARACTER_ANIMATION_PRIORITY: CharacterAnimationClip[] = [
'idle',
'walk',
'run',
'talk',
'inspect',
'use',
]
export const HERMESWORLD_CHARACTER_ARCHETYPES: CharacterArchetype[] = [
{
id: 'player-adventurer',
label: 'Player Adventurer',
zone: 'agora',
role: 'player',
modelPath: '/assets/hermesworld/characters/player-adventurer.glb',
defaultScale: 1,
paletteHint: 'Blue-gold hero silhouette with cleaner semi-real proportions.',
notes: 'First believable player base. Use as camera / control reference for the Agora pass.',
},
{
id: 'oracle-scholar',
label: 'Oracle Scholar',
zone: 'oracle',
role: 'npc',
modelPath: '/assets/hermesworld/characters/oracle-scholar.glb',
defaultScale: 1,
paletteHint: 'Violet-blue robes, mystic trim, readable scholar silhouette.',
notes: 'High-value NPC for questing, prophecy, and talk/gesture animation validation.',
},
{
id: 'forge-blacksmith',
label: 'Forge Blacksmith',
zone: 'forge',
role: 'npc',
modelPath: '/assets/hermesworld/characters/forge-blacksmith.glb',
defaultScale: 1,
paletteHint: 'Warm leather, metal, ember-orange accents.',
notes: 'Use for prop interaction, forge-zone silhouette, and stronger grounded body type.',
},
{
id: 'guard-knight',
label: 'Guard Knight',
zone: 'agora',
role: 'npc',
modelPath: '/assets/hermesworld/characters/guard-knight.glb',
defaultScale: 1,
paletteHint: 'Structured armor silhouette with readable guard posture.',
notes: 'Important for believable town square presence and stronger social framing.',
},
{
id: 'merchant-villager',
label: 'Merchant Villager',
zone: 'agora',
role: 'npc',
modelPath: '/assets/hermesworld/characters/merchant-villager.glb',
defaultScale: 1,
paletteHint: 'Civilian clothing, softer colors, market readability.',
notes: 'Supports prop clusters and makes Agora feel inhabited rather than staged.',
},
{
id: 'villager-common',
label: 'Common Villager',
zone: 'training-grounds',
role: 'npc',
modelPath: '/assets/hermesworld/characters/villager-common.glb',
defaultScale: 1,
paletteHint: 'Simple but believable peasant/traveler look.',
notes: 'Cheap repeatable baseline for believable crowd fill before deeper variety.',
},
]
export const HERMESWORLD_CHARACTER_PIPELINE_NOTES = {
sourcePriority: ['Ready Player Me', 'Mixamo', 'custom GLB cleanup'],
immediateSprint: 'Agora believable',
firstZoneGoal: 'Replace placeholder/toy-like figures with semi-real fantasy humans in Agora first.',
performanceRules: [
'Prefer GLB with compressed textures.',
'Cap material count aggressively.',
'Reuse rigs and animation clips across archetypes.',
'Avoid shipping many unique characters before the first 4-6 feel real.',
],
} as const

View File

@@ -0,0 +1,104 @@
import type { PlaygroundItemId, PlaygroundWorldId } from './playground-rpg'
/**
* HermesWorld action contract.
*
* Agents should operate the world through deterministic verbs, not by
* screen-clicking React controls. This file is the shared protocol surface
* that can be wired into the human UI, background agents, offline progress,
* and future arena/eval combat.
*/
export type PlaygroundActorKind = 'human' | 'agent' | 'npc'
export type PlaygroundActorRef = {
id: string
kind: PlaygroundActorKind
displayName?: string
}
export type PlaygroundAction =
| { kind: 'move_to'; targetId?: string; x?: number; z?: number }
| { kind: 'talk_to'; npcId: string }
| { kind: 'accept_quest'; questId: string }
| { kind: 'complete_objective'; questId: string; objectiveId: string }
| { kind: 'equip'; itemId: PlaygroundItemId }
| { kind: 'travel'; worldId: PlaygroundWorldId }
| { kind: 'attack'; targetId: string; abilityId?: string }
| { kind: 'loot'; itemId?: PlaygroundItemId; targetId?: string }
| { kind: 'rest' }
export type PlaygroundActionResult = {
ok: boolean
action: PlaygroundAction
actor: PlaygroundActorRef
message: string
statePatch?: unknown
emittedEvents?: PlaygroundWorldEvent[]
suggestedNextActions?: PlaygroundAction[]
errorCode?: 'invalid_action' | 'locked' | 'out_of_range' | 'missing_item' | 'cooldown' | 'not_found'
}
export type PlaygroundWorldEvent = {
id: string
ts: number
actorId: string
actorKind: PlaygroundActorKind
type:
| 'move'
| 'dialog'
| 'quest_accept'
| 'objective_complete'
| 'equip'
| 'travel'
| 'combat'
| 'loot'
| 'rest'
worldId?: PlaygroundWorldId
targetId?: string
summary: string
public: boolean
}
export type PlaygroundAgentWorldState = {
actor: PlaygroundActorRef
worldId: PlaygroundWorldId
position: { x: number; z: number }
hp?: number
mp?: number
sp?: number
activeQuestId?: string
activeObjectiveId?: string
unlockedWorlds: PlaygroundWorldId[]
inventory: PlaygroundItemId[]
equipped: Partial<Record<'weapon' | 'cloak' | 'head' | 'artifact', PlaygroundItemId>>
nearby: Array<{
id: string
kind: 'npc' | 'player' | 'item' | 'portal' | 'objective' | 'enemy'
label: string
distance: number
verbs: PlaygroundAction['kind'][]
}>
}
export function describeAction(action: PlaygroundAction): string {
switch (action.kind) {
case 'move_to':
return action.targetId ? `Move to ${action.targetId}` : `Move to ${action.x ?? 0}, ${action.z ?? 0}`
case 'talk_to':
return `Talk to ${action.npcId}`
case 'accept_quest':
return `Accept quest ${action.questId}`
case 'complete_objective':
return `Complete ${action.questId}/${action.objectiveId}`
case 'equip':
return `Equip ${action.itemId}`
case 'travel':
return `Travel to ${action.worldId}`
case 'attack':
return `Attack ${action.targetId}`
case 'loot':
return action.itemId ? `Loot ${action.itemId}` : `Loot ${action.targetId ?? 'target'}`
case 'rest':
return 'Rest'
}
}

View File

@@ -1,5 +1,6 @@
import { Component, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { PlaygroundActionBar } from './components/playground-actionbar'
import { PlaygroundAdminPanel } from './components/playground-admin-panel'
import { PlaygroundChat, type ChatMessage } from './components/playground-chat'
import { PlaygroundCustomizer } from './components/playground-customizer'
import { PlaygroundDialog } from './components/playground-dialog'
@@ -16,6 +17,7 @@ import { autoNarrateWorld, cancelNarration, isNarrationMuted, setNarrationMuted,
import { botsFor } from './lib/playground-bots'
import { itemById, PLAYGROUND_WORLDS, type PlaygroundItemId, type PlaygroundWorldId } from './lib/playground-rpg'
import type { RemotePlayer } from './hooks/use-playground-multiplayer'
import { useWorkspaceStore } from '@/stores/workspace-store'
const WORLD_META: Record<PlaygroundWorldId, { name: string; accent: string }> = {
training: { name: 'Training Grounds', accent: '#5eead4' },
@@ -83,9 +85,27 @@ export function PlaygroundScreen() {
const focusModeAutoEngagedRef = useRef(false)
// Narration mute (Web Speech API). Initialized from persisted state.
const [narrationMuted, setNarrationMutedState] = useState(false)
const [adminMode, setAdminMode] = useState(false)
useEffect(() => {
setNarrationMutedState(isNarrationMuted())
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
const fromUrl = params.get('admin') === '1'
const fromStorage = window.localStorage.getItem('hermes-playground-admin') === '1'
setAdminMode(fromUrl || fromStorage)
}, [])
const toggleAdminMode = () => {
setAdminMode((prev) => {
const next = !prev
if (typeof window !== 'undefined') {
if (next) window.localStorage.setItem('hermes-playground-admin', '1')
else window.localStorage.removeItem('hermes-playground-admin')
}
return next
})
}
const heardToastIds = useRef<Set<string>>(new Set())
const completedTutorialRef = useRef(false)
const lowHpArmedRef = useRef(true)
@@ -222,11 +242,13 @@ export function PlaygroundScreen() {
delete next[bot.id]
return next
})
}, 5000)
}, 4200)
}
window.setTimeout(tick, 6000 + Math.random() * 8000)
// Ambient NPC chatter should make the world feel alive, not drown out
// human chat or inflate product energy. Keep it sparse and clearly local.
window.setTimeout(tick, 18000 + Math.random() * 20000)
}
const initial = window.setTimeout(tick, 2500)
const initial = window.setTimeout(tick, 7000 + Math.random() * 5000)
return () => {
cancelled = true
window.clearTimeout(initial)
@@ -640,6 +662,23 @@ export function PlaygroundScreen() {
{focusMode ? '👁️' : '👁'}
</span>
</button>
{/* Admin mode toggle — shield icon, persistent via localStorage */}
<button
type="button"
onClick={toggleAdminMode}
aria-label={adminMode ? 'Hide admin panel' : 'Show admin panel'}
title={adminMode ? 'Hide admin panel' : 'Show admin panel'}
className="pointer-events-auto fixed right-3 top-[272px] z-[71] hidden h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-black/70 text-[15px] text-white shadow-xl backdrop-blur-xl md:flex"
style={{
boxShadow: adminMode ? '0 0 14px rgba(251,191,36,0.55)' : '0 8px 22px rgba(0,0,0,.55)',
borderColor: adminMode ? 'rgba(251,191,36,0.6)' : 'rgba(255,255,255,0.15)',
}}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
{adminMode ? <path d="m9 12 2 2 4-4" /> : null}
</svg>
</button>
<button
type="button"
onClick={() => setMobileMenuOpen(true)}
@@ -648,6 +687,7 @@ export function PlaygroundScreen() {
Menu
</button>
<PlaygroundHelpHud worldName={WORLD_META[world].name} />
{adminMode ? <PlaygroundAdminPanel /> : null}
<PlaygroundUtilityDock
audioMuted={audioMuted}
narrationMuted={narrationMuted}
@@ -1030,13 +1070,15 @@ function ForgeArrivalOverlay({
function NearbyBuildersChip({ players }: { players: RemotePlayer[] }) {
const [pingedId, setPingedId] = useState<string | null>(null)
const sidebarCollapsed = useWorkspaceStore((s) => s.sidebarCollapsed)
const chromeLeft = sidebarCollapsed ? 'min(120px, 9vw)' : '320px'
if (players.length === 0) return null
return (
<div
className="pointer-events-auto fixed top-[210px] z-[70] hidden w-[220px] rounded-2xl border border-white/15 bg-black/65 p-2 text-white shadow-2xl backdrop-blur-xl md:block"
style={{ left: 'min(120px, 9vw)' }}
style={{ left: chromeLeft }}
>
<div className="mb-1 px-1 text-[9px] font-bold uppercase tracking-[0.16em] text-white/45">Builders Nearby</div>
<div className="space-y-1">

View File

@@ -85,7 +85,7 @@ export function TasksScreen() {
const tasksByColumn = useMemo(() => {
const map: Record<TaskColumn, Array<ClaudeTask>> = {
backlog: [], todo: [], in_progress: [], review: [], done: [],
backlog: [], todo: [], in_progress: [], review: [], blocked: [], done: [],
}
for (const t of tasks) {
if (assigneeFilter && t.assignee !== assigneeFilter) continue
@@ -99,11 +99,12 @@ export function TasksScreen() {
const stats = useMemo(() => {
const total = tasks.length
const inProgress = tasks.filter(t => t.column === 'in_progress').length
const running = tasks.filter(t => t.column === 'in_progress').length
const blocked = tasks.filter(t => t.column === 'blocked').length
const done = tasks.filter(t => t.column === 'done').length
const overdue = tasks.filter(t => isOverdue(t) && t.column !== 'done').length
const completion = total > 0 ? Math.round((done / total) * 100) : 0
return { total, inProgress, done, overdue, completion }
return { total, running, blocked, done, overdue, completion }
}, [tasks])
const invalidate = useCallback(() => {
@@ -198,7 +199,13 @@ export function TasksScreen() {
<div className="flex items-center gap-2 text-xs text-[var(--theme-muted)] flex-wrap">
<span>{stats.total} total</span>
<span className="hidden sm:inline">·</span>
<span className="hidden sm:inline">{stats.inProgress} in progress</span>
<span className="hidden sm:inline">{stats.running} running</span>
{stats.blocked > 0 && (
<>
<span className="hidden sm:inline">·</span>
<span className="text-red-400">{stats.blocked} blocked</span>
</>
)}
{stats.overdue > 0 && (
<>
<span>·</span>

View File

@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
async function loadBackend(options?: {
cards?: Array<Record<string, unknown>>
updatedCard?: Record<string, unknown> | null
}) {
const listKanbanCards = vi.fn(async () => options?.cards ?? [])
const createKanbanCard = vi.fn(async (input) => ({
id: 'card-created',
title: input.title,
spec: input.spec ?? '',
acceptanceCriteria: [],
assignedWorker: input.assignedWorker ?? null,
reviewer: null,
status: input.status ?? 'backlog',
missionId: null,
reportPath: null,
createdBy: input.createdBy ?? 'user',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
}))
const updateKanbanCard = vi.fn(async (_taskId, _updates) => options?.updatedCard ?? null)
const getKanbanBackendMeta = vi.fn(() => ({
id: 'hermes-proxy',
label: 'Hermes Dashboard kanban',
detected: true,
writable: true,
}))
vi.doMock('./kanban-backend', () => ({
listKanbanCards,
createKanbanCard,
updateKanbanCard,
getKanbanBackendMeta,
}))
const mod = await import('./claude-tasks-backend')
return { mod, listKanbanCards, createKanbanCard, updateKanbanCard, getKanbanBackendMeta }
}
describe('claude-tasks-backend', () => {
it('maps shared kanban cards into /tasks records and preserves blocked cards', async () => {
const { mod } = await loadBackend({
cards: [
{
id: 'card-1',
title: 'Blocked card',
spec: 'Investigate runtime edge case',
acceptanceCriteria: [],
assignedWorker: 'swarm6',
reviewer: null,
status: 'blocked',
missionId: null,
reportPath: null,
createdBy: 'aurora',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_050_000,
},
],
})
const tasks = await mod.listClaudeTasks({ includeDone: true })
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({
id: 'card-1',
title: 'Blocked card',
description: 'Investigate runtime edge case',
column: 'blocked',
assignee: 'swarm6',
created_by: 'aurora',
})
})
it('creates tasks in the shared kanban backend instead of tasks.json', async () => {
const { mod, createKanbanCard } = await loadBackend()
const task = await mod.createClaudeTask({
title: 'Wire workspace board to shared kanban',
description: 'Proxy through Agent API',
column: 'todo',
assignee: 'swarm3',
created_by: 'user',
})
expect(createKanbanCard).toHaveBeenCalledWith(expect.objectContaining({
title: 'Wire workspace board to shared kanban',
spec: 'Proxy through Agent API',
assignedWorker: 'swarm3',
status: 'ready',
createdBy: 'user',
}))
expect(task).toMatchObject({ id: 'card-created', column: 'todo', assignee: 'swarm3' })
})
it('moves running and blocked cards through kanban status updates', async () => {
const { mod, updateKanbanCard } = await loadBackend({
updatedCard: {
id: 'card-2',
title: 'Updated card',
spec: 'Now blocked',
acceptanceCriteria: [],
assignedWorker: 'swarm5',
reviewer: null,
status: 'blocked',
missionId: null,
reportPath: null,
createdBy: 'aurora',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_090_000,
},
})
const task = await mod.moveClaudeTask('card-2', 'blocked')
expect(updateKanbanCard).toHaveBeenCalledWith('card-2', expect.objectContaining({ status: 'blocked' }))
expect(task).toMatchObject({ id: 'card-2', column: 'blocked' })
})
})

View File

@@ -0,0 +1,166 @@
import {
createKanbanCard,
listKanbanCards,
type KanbanBackendMeta,
getKanbanBackendMeta,
updateKanbanCard,
} from './kanban-backend'
export type TaskColumn = 'backlog' | 'todo' | 'in_progress' | 'review' | 'blocked' | 'done'
export type TaskPriority = 'high' | 'medium' | 'low'
export type ClaudeTaskRecord = {
id: string
title: string
description: string
column: TaskColumn
priority: TaskPriority
assignee: string | null
tags: string[]
due_date: string | null
position: number
created_by: string
created_at: string
updated_at: string
}
type TaskFilters = {
column?: string | null
assignee?: string | null
priority?: string | null
includeDone?: boolean
}
type CreateTaskInput = {
title: string
description?: string
column?: TaskColumn
priority?: TaskPriority
assignee?: string | null
tags?: string[]
due_date?: string | null
created_by?: string
}
type UpdateTaskInput = Partial<Omit<CreateTaskInput, 'created_by'>>
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString()
}
function mapKanbanStatusToTaskColumn(status: string): TaskColumn {
switch (status) {
case 'ready':
return 'todo'
case 'running':
return 'in_progress'
case 'review':
return 'review'
case 'blocked':
return 'blocked'
case 'done':
return 'done'
case 'backlog':
default:
return 'backlog'
}
}
function mapTaskColumnToKanbanStatus(column: TaskColumn): 'backlog' | 'ready' | 'running' | 'review' | 'blocked' | 'done' {
switch (column) {
case 'todo':
return 'ready'
case 'in_progress':
return 'running'
case 'review':
return 'review'
case 'blocked':
return 'blocked'
case 'done':
return 'done'
case 'backlog':
default:
return 'backlog'
}
}
function mapCardToTask(card: {
id: string
title: string
spec: string
assignedWorker: string | null
status: string
createdBy: string
createdAt: number
updatedAt: number
}): ClaudeTaskRecord {
return {
id: card.id,
title: card.title,
description: card.spec,
column: mapKanbanStatusToTaskColumn(card.status),
priority: 'medium',
assignee: card.assignedWorker,
tags: [],
due_date: null,
position: card.updatedAt,
created_by: card.createdBy,
created_at: toIso(card.createdAt),
updated_at: toIso(card.updatedAt),
}
}
export function getClaudeTasksBackendMeta(): KanbanBackendMeta {
return getKanbanBackendMeta()
}
export async function listClaudeTasks(filters: TaskFilters = {}): Promise<ClaudeTaskRecord[]> {
let tasks = (await listKanbanCards()).map(mapCardToTask)
if (!filters.includeDone) {
tasks = tasks.filter((task) => task.column !== 'done')
}
if (filters.column) {
tasks = tasks.filter((task) => task.column === filters.column)
}
if (filters.assignee) {
tasks = tasks.filter((task) => task.assignee === filters.assignee)
}
if (filters.priority) {
tasks = tasks.filter((task) => task.priority === filters.priority)
}
return tasks.sort((a, b) => b.position - a.position || a.title.localeCompare(b.title))
}
export async function getClaudeTask(taskId: string): Promise<ClaudeTaskRecord | null> {
const tasks = await listKanbanCards()
const card = tasks.find((entry) => entry.id === taskId)
return card ? mapCardToTask(card) : null
}
export async function createClaudeTask(input: CreateTaskInput): Promise<ClaudeTaskRecord> {
const card = await createKanbanCard({
title: input.title,
spec: input.description ?? '',
assignedWorker: input.assignee ?? null,
status: mapTaskColumnToKanbanStatus(input.column ?? 'backlog'),
createdBy: input.created_by ?? 'user',
})
return mapCardToTask(card)
}
export async function updateClaudeTask(taskId: string, updates: UpdateTaskInput): Promise<ClaudeTaskRecord | null> {
const card = await updateKanbanCard(taskId, {
title: typeof updates.title === 'string' ? updates.title : undefined,
spec: typeof updates.description === 'string' ? updates.description : undefined,
assignedWorker:
updates.assignee === null || typeof updates.assignee === 'string'
? updates.assignee
: undefined,
status: updates.column ? mapTaskColumnToKanbanStatus(updates.column) : undefined,
})
return card ? mapCardToTask(card) : null
}
export async function moveClaudeTask(taskId: string, column: TaskColumn): Promise<ClaudeTaskRecord | null> {
return updateClaudeTask(taskId, { column })
}