diff --git a/.gitignore b/.gitignore index 8cf7056f..c65958fc 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,4 @@ skills-bundle/ # Local agent artifacts (audit dumps, temporary playwright scripts) .hermes/ .env.bak-* +pr-triage-20260505-*/ diff --git a/docs/hermesworld/agora-believable-checklist.md b/docs/hermesworld/agora-believable-checklist.md new file mode 100644 index 00000000..59fdb0f7 --- /dev/null +++ b/docs/hermesworld/agora-believable-checklist.md @@ -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 diff --git a/docs/hermesworld/graphics-usability-plan.md b/docs/hermesworld/graphics-usability-plan.md new file mode 100644 index 00000000..17295435 --- /dev/null +++ b/docs/hermesworld/graphics-usability-plan.md @@ -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 diff --git a/docs/hermesworld/master-roadmap.md b/docs/hermesworld/master-roadmap.md new file mode 100644 index 00000000..f3241bf9 --- /dev/null +++ b/docs/hermesworld/master-roadmap.md @@ -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. diff --git a/memory/2026-05-04.md b/memory/2026-05-04.md new file mode 100644 index 00000000..1b490bab --- /dev/null +++ b/memory/2026-05-04.md @@ -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. diff --git a/memory/2026-05-05.md b/memory/2026-05-05.md new file mode 100644 index 00000000..b8c6be0e --- /dev/null +++ b/memory/2026-05-05.md @@ -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. diff --git a/memory/goals/2026-05-03-playground-training-grounds/iterations/008-3002-loading-loop-handoff.md b/memory/goals/2026-05-03-playground-training-grounds/iterations/008-3002-loading-loop-handoff.md new file mode 100644 index 00000000..c4a943a1 --- /dev/null +++ b/memory/goals/2026-05-03-playground-training-grounds/iterations/008-3002-loading-loop-handoff.md @@ -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. diff --git a/memory/goals/2026-05-03-playground-training-grounds/iterations/009-3002-root-cause-found.md b/memory/goals/2026-05-03-playground-training-grounds/iterations/009-3002-root-cause-found.md new file mode 100644 index 00000000..418d795f --- /dev/null +++ b/memory/goals/2026-05-03-playground-training-grounds/iterations/009-3002-root-cause-found.md @@ -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. diff --git a/memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md b/memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md new file mode 100644 index 00000000..ba1d5ca1 --- /dev/null +++ b/memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md @@ -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/.glb` +- legacy `/avatars-3d/.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 session’s 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. diff --git a/memory/swarm/missions/2026-05-05-pr-triage.md b/memory/swarm/missions/2026-05-05-pr-triage.md new file mode 100644 index 00000000..a452c24b --- /dev/null +++ b/memory/swarm/missions/2026-05-05-pr-triage.md @@ -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 # +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. diff --git a/playground-ws-worker/src/worker.ts b/playground-ws-worker/src/worker.ts index ab4be623..5f9edde9 100644 --- a/playground-ws-worker/src/worker.ts +++ b/playground-ws-worker/src/worker.ts @@ -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) diff --git a/playground-ws-worker/wrangler.toml b/playground-ws-worker/wrangler.toml index f2a3429b..15e7900f 100644 --- a/playground-ws-worker/wrangler.toml +++ b/playground-ws-worker/wrangler.toml @@ -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"}] diff --git a/public/assets/hermesworld/characters/README.md b/public/assets/hermesworld/characters/README.md new file mode 100644 index 00000000..eb8ca5f7 --- /dev/null +++ b/public/assets/hermesworld/characters/README.md @@ -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 diff --git a/public/hermesworld-logo.svg b/public/hermesworld-logo.svg new file mode 100644 index 00000000..a28a7f04 --- /dev/null +++ b/public/hermesworld-logo.svg @@ -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> diff --git a/public/hermesworld-logos-reference.png b/public/hermesworld-logos-reference.png new file mode 100644 index 00000000..1dca43f4 Binary files /dev/null and b/public/hermesworld-logos-reference.png differ diff --git a/public/hermesworld-world.png b/public/hermesworld-world.png new file mode 100644 index 00000000..f35426aa Binary files /dev/null and b/public/hermesworld-world.png differ diff --git a/screenshots/hermes-world-landing-pass.png b/screenshots/hermes-world-landing-pass.png new file mode 100644 index 00000000..98d45622 Binary files /dev/null and b/screenshots/hermes-world-landing-pass.png differ diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx index 88a5c79f..f68251f6 100644 --- a/src/components/workspace-shell.tsx +++ b/src/components/workspace-shell.tsx @@ -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} </> ) } diff --git a/src/lib/tasks-api.ts b/src/lib/tasks-api.ts index 3ff8d5ba..c4e6d928 100644 --- a/src/lib/tasks-api.ts +++ b/src/lib/tasks-api.ts @@ -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', } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 8f2325dc..8bfdb4e7 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5e3844f5..e5e33e9f 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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}'; diff --git a/src/routes/api/claude-tasks.$taskId.ts b/src/routes/api/claude-tasks.$taskId.ts index 93b93293..c9d49da4 100644 --- a/src/routes/api/claude-tasks.$taskId.ts +++ b/src/routes/api/claude-tasks.$taskId.ts @@ -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 { diff --git a/src/routes/api/claude-tasks.ts b/src/routes/api/claude-tasks.ts index ad7aa9d5..6b1d731b 100644 --- a/src/routes/api/claude-tasks.ts +++ b/src/routes/api/claude-tasks.ts @@ -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', }) diff --git a/src/routes/api/playground-admin.ts b/src/routes/api/playground-admin.ts new file mode 100644 index 00000000..a2786366 --- /dev/null +++ b/src/routes/api/playground-admin.ts @@ -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 }) + } + }, + }, + }, +}) diff --git a/src/routes/hermes-world.tsx b/src/routes/hermes-world.tsx new file mode 100644 index 00000000..33c215ab --- /dev/null +++ b/src/routes/hermes-world.tsx @@ -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 /> +} diff --git a/src/routes/world.tsx b/src/routes/world.tsx new file mode 100644 index 00000000..6f8d000a --- /dev/null +++ b/src/routes/world.tsx @@ -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 /> +} diff --git a/src/screens/playground/components/npc-character.tsx b/src/screens/playground/components/npc-character.tsx new file mode 100644 index 00000000..d09bcd8c --- /dev/null +++ b/src/screens/playground/components/npc-character.tsx @@ -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' + } +} diff --git a/src/screens/playground/components/player-character.tsx b/src/screens/playground/components/player-character.tsx new file mode 100644 index 00000000..1d7399d1 --- /dev/null +++ b/src/screens/playground/components/player-character.tsx @@ -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> + ) +} diff --git a/src/screens/playground/components/playground-admin-panel.tsx b/src/screens/playground/components/playground-admin-panel.tsx new file mode 100644 index 00000000..11bf7074 --- /dev/null +++ b/src/screens/playground/components/playground-admin-panel.tsx @@ -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> +} diff --git a/src/screens/playground/components/playground-chat.tsx b/src/screens/playground/components/playground-chat.tsx index 0e10f2f2..48de0119 100644 --- a/src/screens/playground/components/playground-chat.tsx +++ b/src/screens/playground/components/playground-chat.tsx @@ -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> + ) +} diff --git a/src/screens/playground/components/playground-glb-body.tsx b/src/screens/playground/components/playground-glb-body.tsx new file mode 100644 index 00000000..588fb4ec --- /dev/null +++ b/src/screens/playground/components/playground-glb-body.tsx @@ -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> + ) +} diff --git a/src/screens/playground/components/playground-hud.tsx b/src/screens/playground/components/playground-hud.tsx index dd96b746..6e4db013 100644 --- a/src/screens/playground/components/playground-hud.tsx +++ b/src/screens/playground/components/playground-hud.tsx @@ -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 diff --git a/src/screens/playground/components/playground-npc-glb.tsx b/src/screens/playground/components/playground-npc-glb.tsx index 33a2a5dc..a00c0a0d 100644 --- a/src/screens/playground/components/playground-npc-glb.tsx +++ b/src/screens/playground/components/playground-npc-glb.tsx @@ -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} /> + ))} + </> ) } diff --git a/src/screens/playground/components/playground-player-glb.tsx b/src/screens/playground/components/playground-player-glb.tsx new file mode 100644 index 00000000..61311c3c --- /dev/null +++ b/src/screens/playground/components/playground-player-glb.tsx @@ -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} /> + ))} + </> + ) +} diff --git a/src/screens/playground/components/playground-world-3d.tsx b/src/screens/playground/components/playground-world-3d.tsx index aebb71af..328a7878 100644 --- a/src/screens/playground/components/playground-world-3d.tsx +++ b/src/screens/playground/components/playground-world-3d.tsx @@ -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 trainer’s 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]}> - <boxGeometry args={[0.7, 3, 0.7]} /> - <meshStandardMaterial color="#0f172a" emissive="#22d3ee" emissiveIntensity={0.25} /> - </mesh> + <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.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' && ( diff --git a/src/screens/playground/hermes-world-landing.tsx b/src/screens/playground/hermes-world-landing.tsx new file mode 100644 index 00000000..2b371936 --- /dev/null +++ b/src/screens/playground/hermes-world-landing.tsx @@ -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]">Today’s 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> + ) +} diff --git a/src/screens/playground/lib/character-config.ts b/src/screens/playground/lib/character-config.ts new file mode 100644 index 00000000..b9fb09b4 --- /dev/null +++ b/src/screens/playground/lib/character-config.ts @@ -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 diff --git a/src/screens/playground/lib/playground-actions.ts b/src/screens/playground/lib/playground-actions.ts new file mode 100644 index 00000000..e438e71f --- /dev/null +++ b/src/screens/playground/lib/playground-actions.ts @@ -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' + } +} diff --git a/src/screens/playground/playground-screen.tsx b/src/screens/playground/playground-screen.tsx index f33fc163..37c7b653 100644 --- a/src/screens/playground/playground-screen.tsx +++ b/src/screens/playground/playground-screen.tsx @@ -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"> diff --git a/src/screens/tasks/tasks-screen.tsx b/src/screens/tasks/tasks-screen.tsx index 5412dd8c..a9c5eb10 100644 --- a/src/screens/tasks/tasks-screen.tsx +++ b/src/screens/tasks/tasks-screen.tsx @@ -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> diff --git a/src/server/claude-tasks-backend.test.ts b/src/server/claude-tasks-backend.test.ts new file mode 100644 index 00000000..22c6e78a --- /dev/null +++ b/src/server/claude-tasks-backend.test.ts @@ -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' }) + }) +}) diff --git a/src/server/claude-tasks-backend.ts b/src/server/claude-tasks-backend.ts new file mode 100644 index 00000000..5d62b6e3 --- /dev/null +++ b/src/server/claude-tasks-backend.ts @@ -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 }) +}