* wip(hermesworld): viral sprint checkpoint - landing rebuild + character pipeline scaffold - standalone /hermes-world and /world routes bypass workspace shell - root overlay leaks gated for landing + game surfaces - character pipeline scaffolding (player/npc/glb-body components) - canonical asset path public/assets/hermesworld/characters/ - docs: landing-page-spec, graphics-usability-plan, agora-believable-checklist, master-roadmap - handoff at memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md Local-only checkpoint. Not for upstream yet. * feat(playground): persistent admin mode toggle with shield button - Admin mode now persists via localStorage (key: hermes-playground-admin) - Shield icon button in HUD (right rail, below focus toggle, md+) - Click toggles admin panel and saves preference - ?admin=1 URL param still works as override - gitignore swarm worker scratch dirs Mission: memory/swarm/missions/2026-05-05-pr-triage.md (5 swarm lanes dispatched on 19 open PRs, no-merge contract) * feat(landing): add Play Now CTAs to HermesWorld landing - Hero: Play Now (primary, gold), View on GitHub (demoted), Read Roadmap - Header nav: Play badge (highlighted gold) - Final CTA: Play Now (primary), GitHub + Roadmap (secondary) All Play buttons go to /playground which mounts the title screen (username + character customizer + Enter). Sets up the public-URL deploy: hermes-world.ai → / serves landing → click Play → /playground. * fix(tasks): use shared kanban backend --------- Co-authored-by: Aurora release bot <release@outsourc-e.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -145,3 +145,4 @@ skills-bundle/
|
||||
# Local agent artifacts (audit dumps, temporary playwright scripts)
|
||||
.hermes/
|
||||
.env.bak-*
|
||||
pr-triage-20260505-*/
|
||||
|
||||
48
docs/hermesworld/agora-believable-checklist.md
Normal file
48
docs/hermesworld/agora-believable-checklist.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Agora Believable Checklist
|
||||
|
||||
Status: active first implementation slice
|
||||
Owner: Eric / Aurora
|
||||
|
||||
## Objective
|
||||
Turn Agora into the first zone that feels like a real game scene instead of a promising prototype.
|
||||
|
||||
## Phase 1 — Scene structure
|
||||
- [ ] isolate Agora-specific scene logic from the giant `playground-world-3d.tsx`
|
||||
- [ ] identify current player model/render path
|
||||
- [ ] identify current NPC render path
|
||||
- [ ] define where `PlayerCharacter` and `NpcCharacter` will mount
|
||||
|
||||
## Phase 2 — Characters
|
||||
- [x] scaffold character archetype config
|
||||
- [x] scaffold `PlayerCharacter` component boundary
|
||||
- [x] scaffold `NpcCharacter` component boundary
|
||||
- [ ] replace one player stand-in with `PlayerCharacter`
|
||||
- [ ] replace one guard/oracle NPC stand-in with `NpcCharacter`
|
||||
- [ ] wire label + selection behavior to new character components
|
||||
|
||||
## Phase 3 — Agora composition
|
||||
- [ ] strengthen central monument silhouette
|
||||
- [ ] improve radial stone paving / circular plaza readability
|
||||
- [ ] cluster benches / stalls / torches more intentionally
|
||||
- [ ] place NPCs in authored conversational groups
|
||||
- [ ] remove any obviously toy-like placeholder blocking
|
||||
|
||||
## Phase 4 — Lighting and atmosphere
|
||||
- [ ] improve key light direction
|
||||
- [ ] add stronger warm firelight pools
|
||||
- [ ] add controlled fog / distance atmosphere
|
||||
- [ ] tune bloom/post so the scene feels rich, not blurry
|
||||
|
||||
## Phase 5 — HUD and readability
|
||||
- [ ] tighten objective panel
|
||||
- [ ] reduce minimap visual noise
|
||||
- [ ] improve NPC label readability
|
||||
- [ ] improve interaction prompts
|
||||
- [ ] make bottom action bar read more like game UI, less prototype
|
||||
|
||||
## Success criteria
|
||||
- [ ] player character looks like a believable human silhouette
|
||||
- [ ] at least 3 NPCs feel believable and differentiated
|
||||
- [ ] Agora screenshot looks postable on X without apology
|
||||
- [ ] objective flow is obvious at first glance
|
||||
- [ ] no leftover workspace-local chrome leaks into the public/game-facing surface
|
||||
309
docs/hermesworld/graphics-usability-plan.md
Normal file
309
docs/hermesworld/graphics-usability-plan.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# HermesWorld Graphics + Usability Development Plan
|
||||
|
||||
Status: start now
|
||||
Owner: Eric / Aurora
|
||||
Stack: Hermes Workspace + React + Three.js / React Three Fiber
|
||||
|
||||
## Goal
|
||||
|
||||
Upgrade HermesWorld from "promising web demo" to "serious playable world" with:
|
||||
- stronger visual identity
|
||||
- much better usability
|
||||
- more believable characters
|
||||
- better screenshot / clip quality
|
||||
- a clean path that stays web-native
|
||||
|
||||
## Core decision
|
||||
|
||||
Do **not** switch engines now.
|
||||
|
||||
Stay on:
|
||||
- **Three.js**
|
||||
- **React Three Fiber**
|
||||
- current Hermes Workspace integration
|
||||
|
||||
Reason:
|
||||
- web shareability matters
|
||||
- X traffic matters
|
||||
- embedding/dashboard mode matters
|
||||
- current blockers are art direction, assets, rendering, and UX, not engine choice
|
||||
|
||||
## Hard truth on "real-looking characters"
|
||||
|
||||
### What is realistic right now
|
||||
We can get to:
|
||||
- **stylized-real / semi-real / premium MMO-lite**
|
||||
- strong silhouettes
|
||||
- better faces/hair/clothes
|
||||
- better animation
|
||||
- more human proportions
|
||||
- better materials and lighting
|
||||
|
||||
### What is not realistic right now
|
||||
Not this sprint:
|
||||
- AAA Unreal photoreal humans
|
||||
- fully custom hero character system from scratch
|
||||
- hundreds of unique high-end characters
|
||||
|
||||
## Recommended character direction
|
||||
|
||||
Target look:
|
||||
- **semi-real fantasy RPG characters**
|
||||
- more grounded than Roblox / low-poly toy figures
|
||||
- less uncanny than rushed photoreal
|
||||
- readable at gameplay distance
|
||||
- works in browser performance budgets
|
||||
|
||||
## Character pipeline to start now
|
||||
|
||||
### Best first path
|
||||
Use a proven character source instead of inventing characters from primitives.
|
||||
|
||||
Recommended order:
|
||||
1. **Ready Player Me** or equivalent avatar source for fast believable human bases
|
||||
2. **Mixamo** for animation clips
|
||||
3. export to **GLB**
|
||||
4. optimize materials / texture sizes for browser
|
||||
5. adapt wardrobe/colors to HermesWorld visual language
|
||||
|
||||
### Why this path
|
||||
- gets believable humans fast
|
||||
- works with browser GLB pipeline
|
||||
- animation is solved sooner
|
||||
- lets us focus on world + UX instead of making characters from zero
|
||||
|
||||
### First character set we need
|
||||
- player base male
|
||||
- player base female
|
||||
- scholar / oracle NPC
|
||||
- blacksmith / forge NPC
|
||||
- guard / knight NPC
|
||||
- merchant / villager NPC
|
||||
|
||||
Do **not** start with huge variety.
|
||||
Get 4-6 great archetypes first.
|
||||
|
||||
## Animation pipeline
|
||||
|
||||
### First animation pack
|
||||
Need:
|
||||
- idle
|
||||
- walk
|
||||
- run
|
||||
- talk / gesture
|
||||
- inspect / use
|
||||
- celebrate / emote
|
||||
- sit or kneel if easy
|
||||
|
||||
### Character behavior goals
|
||||
- no stiff statue NPCs
|
||||
- idle should feel alive
|
||||
- movement should read clearly from distance
|
||||
- talking should feel intentional even before lip sync
|
||||
|
||||
## Environment upgrade priorities
|
||||
|
||||
## 1. Landmark pass
|
||||
Every zone needs strong silhouette.
|
||||
|
||||
Immediate targets:
|
||||
- Agora central monument / obelisk / sigil altar
|
||||
- Oracle tower / ring structure
|
||||
- Forge furnace / chimney / heat source
|
||||
- Grove memory tree / crystal tree / archive roots
|
||||
- Arena ring / banners / gates
|
||||
- Training Grounds camp / gate / tutorial shrine
|
||||
|
||||
## 2. Ground + path pass
|
||||
Fix the current "objects on a plane" feeling.
|
||||
|
||||
Need:
|
||||
- stronger stone paths
|
||||
- path borders
|
||||
- elevation changes
|
||||
- stairs / slopes / platform edges
|
||||
- clustered vegetation instead of random scatter
|
||||
|
||||
## 3. Prop clustering
|
||||
Move from generic scattered props to authored compositions.
|
||||
|
||||
Need:
|
||||
- market stalls that actually compose into scenes
|
||||
- bench / torch / crate / barrel clusters
|
||||
- decorative banners and signposts
|
||||
- repeating prop kits per zone
|
||||
|
||||
## 4. Lighting / atmosphere
|
||||
Highest ROI visual upgrade.
|
||||
|
||||
Need:
|
||||
- stronger key light direction
|
||||
- fog / distance atmosphere
|
||||
- warm firelight pools
|
||||
- cooler shadow balance
|
||||
- subtle bloom / post processing
|
||||
- skybox that supports mythic mood
|
||||
|
||||
## Usability upgrade priorities
|
||||
|
||||
## 1. Readability of objective flow
|
||||
The user should always know:
|
||||
- where they are
|
||||
- what to do next
|
||||
- what is interactable
|
||||
- what their agents are doing
|
||||
|
||||
### Immediate changes
|
||||
- clearer quest objective widget
|
||||
- stronger waypoint / marker language
|
||||
- better hover/interact outlines
|
||||
- more distinct NPC labels
|
||||
|
||||
## 2. HUD cleanup
|
||||
Current HUD should feel more like a game and less like mixed prototype layers.
|
||||
|
||||
Need:
|
||||
- one consistent HUD material language
|
||||
- clearer bottom action bar
|
||||
- tighter minimap frame
|
||||
- cleaner player stats card
|
||||
- less visual noise from debug/admin leftovers
|
||||
|
||||
## 3. Chat / social UX
|
||||
Chat should not dominate the scene.
|
||||
|
||||
Need:
|
||||
- smaller, cleaner chat panel
|
||||
- better contrast and message hierarchy
|
||||
- easier collapse/minimize behavior
|
||||
- less overlap with gameplay space
|
||||
|
||||
## 4. Input / interaction cues
|
||||
Need immediate affordances:
|
||||
- interact prompt treatment
|
||||
- click target confidence
|
||||
- path-to-target feedback
|
||||
- clearer selected NPC / object state
|
||||
|
||||
## Performance plan
|
||||
|
||||
This all has to stay browser-safe.
|
||||
|
||||
### Rules
|
||||
- prefer **instancing** for repeated props
|
||||
- reduce unique materials
|
||||
- compress textures
|
||||
- cap texture resolution aggressively
|
||||
- use LOD where needed
|
||||
- keep postprocessing restrained
|
||||
- test draw calls after each art pass
|
||||
|
||||
### Budget mindset
|
||||
For each major scene ask:
|
||||
- how many characters on screen?
|
||||
- how many unique materials?
|
||||
- how many dynamic lights?
|
||||
- how many transparent effects?
|
||||
|
||||
## Development tracks
|
||||
|
||||
## Track A — Characters
|
||||
Deliver believable people fast.
|
||||
|
||||
### Start now
|
||||
- choose character source
|
||||
- pick 4-6 NPC archetypes
|
||||
- pick 1 player archetype
|
||||
- get GLB import path working cleanly
|
||||
- wire idle + walk + talk test scene
|
||||
|
||||
## Track B — Agora visual remake
|
||||
Use Agora as the first gold-standard zone.
|
||||
|
||||
### Start now
|
||||
- rebuild central plaza composition
|
||||
- improve stone ground / path circles
|
||||
- add stronger firelight and monument detail
|
||||
- replace placeholder NPC bodies with first believable characters
|
||||
|
||||
## Track C — HUD / usability pass
|
||||
### Start now
|
||||
- objective widget redesign
|
||||
- minimap cleanup
|
||||
- stats card cleanup
|
||||
- interaction marker cleanup
|
||||
- remove any workspace-local/debug carryover from game-facing UI
|
||||
|
||||
## Track D — Asset pipeline
|
||||
### Start now
|
||||
- standardize GLB imports
|
||||
- define texture size limits
|
||||
- define naming conventions
|
||||
- define animation clip naming
|
||||
- define NPC archetype list
|
||||
|
||||
## Immediate first sprint
|
||||
|
||||
### Sprint 1: "Agora believable"
|
||||
Make only the Agora look and feel dramatically better.
|
||||
|
||||
Ship these first:
|
||||
1. one upgraded player character
|
||||
2. three upgraded NPC archetypes
|
||||
3. better idle/walk/talk animations
|
||||
4. rebuilt central plaza composition
|
||||
5. better firelight/fog/post
|
||||
6. cleaned HUD around objective + minimap + bottom bar
|
||||
|
||||
If Agora feels real, the rest of the world becomes believable.
|
||||
|
||||
## Exact first implementation steps
|
||||
|
||||
### Step 1
|
||||
Replace current primitive character stand-ins with imported GLB humanoids.
|
||||
|
||||
### Step 2
|
||||
Add animation controller for:
|
||||
- idle
|
||||
- walk
|
||||
- gesture/talk
|
||||
|
||||
### Step 3
|
||||
Rebuild Agora center using:
|
||||
- stronger monument
|
||||
- radial paving
|
||||
- authored prop clusters
|
||||
- meaningful NPC placement
|
||||
|
||||
### Step 4
|
||||
Polish camera and UI for the new scene.
|
||||
|
||||
### Step 5
|
||||
Capture screenshots/clips and tune from what looks weak.
|
||||
|
||||
## Success criteria
|
||||
|
||||
We know this is working when:
|
||||
- a screenshot reads like a real game scene, not a prototype
|
||||
- characters look like people, not placeholders
|
||||
- the objective and interactions are obvious
|
||||
- the page is clip-worthy on X
|
||||
- the browser still runs smoothly
|
||||
|
||||
## My recommendation
|
||||
|
||||
Start **now** with:
|
||||
1. character pipeline
|
||||
2. Agora remake
|
||||
3. HUD cleanup
|
||||
|
||||
That is the shortest path to the result you actually want.
|
||||
|
||||
## Immediate next task
|
||||
|
||||
Implement the first "Agora believable" pass:
|
||||
- import better humanoid characters
|
||||
- wire idle/walk/talk clips
|
||||
- rebuild the central plaza composition around those characters
|
||||
- tune lighting/fog/post
|
||||
- clean HUD overlap
|
||||
202
docs/hermesworld/master-roadmap.md
Normal file
202
docs/hermesworld/master-roadmap.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# HermesWorld Master Roadmap
|
||||
|
||||
Status: active build sprint
|
||||
Owner: Eric / Aurora
|
||||
Repo: `outsourc-e/hermes-workspace`
|
||||
Scope: HermesWorld inside Hermes Workspace, dashboard/plugin embedded first, standalone later
|
||||
|
||||
## Product thesis
|
||||
|
||||
HermesWorld is no longer a novelty route. It is the playable layer for Hermes Workspace: a persistent world where humans and agents can move, talk, complete missions, unlock progression, and eventually keep working while the human is away.
|
||||
|
||||
Keep HermesWorld in `hermes-workspace` for now. The tight coupling to workspace state, sessions, agents, plugins, quests, and dashboard embeds is the feature. A standalone destination can ship as a route/deploy target later without splitting source.
|
||||
|
||||
## North star
|
||||
|
||||
HermesWorld should become:
|
||||
|
||||
- **playable by humans**: polished RPG/MMO onboarding, chat, quests, inventory, progression, multiplayer presence
|
||||
- **operable by agents**: deterministic action verbs, quests, travel, equipment, combat/evals, offline progression
|
||||
- **persistent**: durable player profiles, world events, analytics, session handoff, reconnect truth
|
||||
- **dashboard-embeddable**: first-class private/admin and public embed surfaces
|
||||
- **standalone shareable**: public landing/deep links when the product surface is ready
|
||||
|
||||
## This week, shipping track
|
||||
|
||||
### 1. Admin UX cleanup
|
||||
|
||||
Ship a dashboard/plugin-only admin surface for Eric. Do not put admin controls on the public loading page.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Private admin route/panel gated to local/private access and admin token.
|
||||
- Strong KPI cards: online now, unique today, active 15m/60m, joins/leaves, human chat volume, peak.
|
||||
- Recent players with world, last seen, last chat, join/chat counts.
|
||||
- Recent events with clear type styling and world labels.
|
||||
- Human vs NPC truth called out explicitly. Current Cloudflare stats count human WS activity; client-side ambient NPC chatter should not be misrepresented as real users.
|
||||
- Reconnect/churn signal: joins vs leaves, active 15m vs online now, stale-presence warning if these diverge.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Eric can open the admin surface from a private/dashboard context and immediately understand live health.
|
||||
- Stats do not pretend bots are humans.
|
||||
- Recent player/event lists are scannable at a glance.
|
||||
|
||||
### 2. Visual polish pass
|
||||
|
||||
Execute `docs/hermesworld/visual-upgrade-spec.md` aggressively. TinySkies is the atmosphere/environment reference, not the code architecture.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Zone-specific camera/lighting/fog/sky tuning.
|
||||
- Landmark emphasis in every zone.
|
||||
- More readable paths and objective direction.
|
||||
- Denser low-poly props without clutter.
|
||||
- HUD/chat/nameplates move from dev-tool glass to premium game UI.
|
||||
- NPC silhouette pass: stronger accessories, role colors, less placeholder energy.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Screenshots look meaningfully more premium before any explanation.
|
||||
- Each zone reads from one frame.
|
||||
- The player sees where to go without reading a paragraph.
|
||||
|
||||
### 3. Chat / NPC cleanup
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Reduce ambient NPC message flooding.
|
||||
- Separate human chat from ambient/NPC chatter in the UI.
|
||||
- Label local fallback/bot presence honestly.
|
||||
- Keep multiplayer chat useful for humans, not drowned by scripted lines.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Human messages are visually dominant.
|
||||
- NPC flavor adds life but does not spam.
|
||||
- Admin metrics and chat labels use truthful language.
|
||||
|
||||
### 4. Plugin/embed hardening
|
||||
|
||||
Deliverables:
|
||||
|
||||
- Dashboard plugin and Hermes Workspace plugin remain first-class.
|
||||
- `embed=1` mode stays clean and chrome-free where appropriate.
|
||||
- Plugin install flow remains git-based and obvious.
|
||||
- Admin stays private/plugin-only.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Public users see the world, not admin machinery.
|
||||
- Dashboard users can embed/control without layout weirdness.
|
||||
|
||||
## Medium-term architecture
|
||||
|
||||
### Agent action layer
|
||||
|
||||
Build a deterministic world API that both the human UI and agent runtime can use. Avoid UI hacks like clicking DOM nodes from an agent.
|
||||
|
||||
Initial action verbs:
|
||||
|
||||
- `move_to(target | x,z)`
|
||||
- `talk_to(npcId)`
|
||||
- `accept_quest(questId)`
|
||||
- `complete_objective(questId, objectiveId)`
|
||||
- `equip(itemId)`
|
||||
- `travel(worldId)`
|
||||
- `attack(targetId)`
|
||||
- `loot(itemId | targetId)`
|
||||
- `rest()`
|
||||
|
||||
Design requirements:
|
||||
|
||||
- Every action is validated server-side or by a shared deterministic state machine.
|
||||
- Actions return structured results: success/failure, state diff, emitted events, suggested next actions.
|
||||
- Human UI should call the same action layer where possible.
|
||||
- Agents should be able to plan from world state, not screenshots.
|
||||
|
||||
### Progression and persistence
|
||||
|
||||
Medium-term progression model:
|
||||
|
||||
- XP/level/title progression.
|
||||
- Quest chains per zone.
|
||||
- Inventory/equipment affecting verbs.
|
||||
- Unlockable travel gates.
|
||||
- World event log.
|
||||
- Durable profile storage beyond localStorage.
|
||||
|
||||
Persistence stages:
|
||||
|
||||
1. localStorage profile, current
|
||||
2. dashboard/plugin-backed profile sync
|
||||
3. account/session-backed cloud profile
|
||||
4. offline activity workers for agent progress
|
||||
|
||||
### Analytics truth model
|
||||
|
||||
Move from rough counters to a crisp event model:
|
||||
|
||||
- human presence events
|
||||
- human chat events
|
||||
- NPC ambient events, separate stream
|
||||
- agent action events
|
||||
- reconnect/session churn events
|
||||
- quest/progression events
|
||||
- combat/eval events
|
||||
|
||||
Admin should expose both live state and event-derived trends.
|
||||
|
||||
## Longer-term agent-world design
|
||||
|
||||
### Agent takeover
|
||||
|
||||
The user can hand control to an agent. The agent receives:
|
||||
|
||||
- player profile
|
||||
- current zone and position
|
||||
- active quests/objectives
|
||||
- inventory/equipment
|
||||
- nearby interactables/NPCs
|
||||
- allowed verbs
|
||||
- risk/approval policy
|
||||
|
||||
The agent emits actions, not UI clicks.
|
||||
|
||||
### Offline progression
|
||||
|
||||
When the user sleeps, their agent can keep progressing inside bounded rules:
|
||||
|
||||
- user-configured goal, for example `level to 5`, `finish Training Grounds`, `farm Forge shards`
|
||||
- max time/resource budget
|
||||
- safe action allowlist
|
||||
- summarized event log on return
|
||||
- no irreversible marketplace/public actions without approval
|
||||
|
||||
### Agent-to-agent combat / battle loop
|
||||
|
||||
Future battle loop should reuse eval concepts:
|
||||
|
||||
- arena match = structured task/eval
|
||||
- agents choose abilities/tools/models
|
||||
- scoring combines objective result, speed, cost, style/quality
|
||||
- loot/rewards map back to workspace abilities or cosmetics
|
||||
|
||||
This makes HermesWorld a visible layer for agent benchmarking and learning, not just a toy combat system.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Admin panel cleanup and truthful metrics labels.
|
||||
2. Chat/NPC flood reduction and separation.
|
||||
3. Visual atmosphere constants and HUD material pass.
|
||||
4. Landmark/path readability pass per zone.
|
||||
5. Shared action-layer spec in code (`lib/playground-actions.ts`) with types first.
|
||||
6. Wire one real action path through the type layer, likely `travel` or `equip`.
|
||||
7. Standalone landing surface once the embedded product feels premium.
|
||||
|
||||
## Open risks
|
||||
|
||||
- Cloudflare relay analytics are useful but still approximate if tabs hibernate/reconnect aggressively.
|
||||
- Local bot/NPC activity can make the world feel alive, but must never pollute human metrics.
|
||||
- Visual richness must stay lightweight, no giant asset pipeline yet.
|
||||
- Agent-playable systems need deterministic state transitions before autonomy, otherwise agents will become brittle screen-clickers.
|
||||
9
memory/2026-05-04.md
Normal file
9
memory/2026-05-04.md
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
## Late-night HermesWorld product sprint kickoff
|
||||
|
||||
- Eric started a fresh GPT-5.5 HermesWorld build sprint and reframed HermesWorld as a real product, not just a demo. Keep it inside `hermes-workspace`; dashboard/plugin embed remains first-class; standalone/shareable can come later without splitting source.
|
||||
- Product direction locked: playable by humans, operable by agents, persistent, dashboard-embeddable, eventually standalone. Admin should be private/dashboard-only, not on the loading page.
|
||||
- Created `docs/hermesworld/master-roadmap.md` organizing this week's shipping work, medium-term architecture, and long-term agent-world design.
|
||||
- First local implementation slice: upgraded admin panel UI/truth labels, separated human vs ambient NPC chat in the chat UI, throttled ambient NPC chatter, tuned zone atmosphere/lighting/fog constants, and added `src/screens/playground/lib/playground-actions.ts` as the deterministic action protocol scaffold for future agent play.
|
||||
- `pnpm build` passed after the slice. Existing local dirty files from prior work remain mixed with new sprint files; do not assume only this sprint changed the worktree.
|
||||
- Continued HermesWorld local-only polish: added readable glowing route ribbons in Training Grounds, upgraded Forge Gate landmark with stronger portal/plinth/light treatment, and refined HUD materials toward premium game panels. `pnpm build` passed again.
|
||||
7
memory/2026-05-05.md
Normal file
7
memory/2026-05-05.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 2026-05-05
|
||||
|
||||
- HermesWorld local-only sprint continued during trending/public-momentum moment. User explicitly wants everything local until graphics/polish are strong.
|
||||
- Added additional hero landmark visuals locally in `/Users/aurora/hermes-workspace`: Forge super-furnace centerpiece, Grove memory tree, Oracle rotating ring tower, Arena inner medallion/spotlight, plus prior Training Grounds route ribbons/Forge Gate/HUD polish. `pnpm build` passed after the landmark pass.
|
||||
- Eric bought `hermes-world.ai` and wants a landing page shipped ASAP. Built local HermesWorld landing routes `/hermes-world` and `/world` with a screenshot-2-inspired low-poly diorama hero, zone cards, launch CTAs, and domain-ready copy. Build passed. Next deploy decision needed: map `hermes-world.ai` root to this landing route and `/playground` to the playable app.
|
||||
- Eric said landing page was too basic and needs Hermes Workspace design language, video/preview inside hero, stronger viral vibe, and asked whether to move off web. Reworked landing UI locally: dark Hermes gold/cyan design language, live `/playground?embed=1` iframe inside the hero and trailer section, clearer explanation that graphics updates are inside the game build, Sigils token-as-game-lore section. Build passed. Recommendation given: web first for viral link distribution, native/off-web later after shareable demo loop is proven.
|
||||
- Wrote full resume handoff for the next `/new` session at `memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md`, including current local state, landing/game work completed, latest Downloads refs (`landing.png`, `logos.png`, `world.png`), priority order, and the grounded recommendation to keep web first while exploring crazier future packaging later.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
246
memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md
Normal file
246
memory/goals/2026-05-05-hermesworld-viral-sprint/handoff.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# HermesWorld Viral Sprint Handoff
|
||||
|
||||
Date: 2026-05-05 14:22 EDT
|
||||
Owner: Eric / Aurora
|
||||
Repo: `/Users/aurora/hermes-workspace`
|
||||
Status: local-only, uncommitted, handoff for next Opus session
|
||||
|
||||
## What landed this session
|
||||
|
||||
### 1. HermesWorld landing was rebuilt into a standalone public surface
|
||||
Routes in repo:
|
||||
- `/hermes-world`
|
||||
- `/world`
|
||||
|
||||
Key file:
|
||||
- `src/screens/playground/hermes-world-landing.tsx`
|
||||
|
||||
Landing now has:
|
||||
- world-first public launch structure
|
||||
- hero, capability strip, launch-drop section, zones, agents, sigils, final CTA, footer
|
||||
- GitHub / roadmap / feature-list links instead of local-only `/playground` public CTAs
|
||||
- stronger HermesWorld-specific copy
|
||||
- launch-day messaging like:
|
||||
- “Dropping HermesWorld today.”
|
||||
- landing page / roadmap / feature list / graphics sprint
|
||||
|
||||
### 2. HermesWorld landing now bypasses workspace chrome
|
||||
The biggest source of confusion was root/app-shell leakage.
|
||||
|
||||
Fixed or partially fixed:
|
||||
- `/hermes-world` and `/world` now bypass the **WorkspaceShell** and scroll like normal pages
|
||||
- local-only workspace chrome removed from the landing route
|
||||
- app splash skipped on HermesWorld landing routes
|
||||
- route is intended to be document-scrollable, not app-pane scrollable
|
||||
|
||||
Touched:
|
||||
- `src/components/workspace-shell.tsx`
|
||||
- `src/routes/__root.tsx`
|
||||
|
||||
### 3. Multiple root-level overlays were traced and removed/gated
|
||||
Found and addressed these root-level leak sources:
|
||||
- `UsageMeter`
|
||||
- `SearchModal`
|
||||
- `KeyboardShortcutsModal`
|
||||
- `UpdateCenterNotifier`
|
||||
- `MobilePromptTrigger`
|
||||
- `OnboardingTour`
|
||||
|
||||
Important note:
|
||||
- Some of these were gated only for landing routes.
|
||||
- Later, game surfaces like `/playground` also needed special treatment.
|
||||
|
||||
### 4. Bottom-right usage/session pill leak on HermesWorld/game surfaces was addressed
|
||||
This `SESSION | IN | OUT | CTX | COST` widget was another workspace overlay leak.
|
||||
|
||||
Action taken:
|
||||
- hid the global usage/session pill for **game surfaces**, not just the landing page
|
||||
|
||||
Touched:
|
||||
- `src/routes/__root.tsx`
|
||||
|
||||
### 5. Sidebar overlap bugs in `/playground` were addressed
|
||||
On 3002 with the left sidebar open, gameplay UI elements overlapped badly.
|
||||
|
||||
Fix direction implemented:
|
||||
- made several game UI pieces sidebar-aware so they shift when the left sidebar is open
|
||||
|
||||
Touched:
|
||||
- `src/screens/playground/components/playground-hud.tsx`
|
||||
- `src/screens/playground/components/playground-chat.tsx`
|
||||
- `src/screens/playground/playground-screen.tsx`
|
||||
|
||||
The intended fix was:
|
||||
- use `useWorkspaceStore((s) => s.sidebarCollapsed)`
|
||||
- shift HUD/chat/chips to `320px` left offset when sidebar is open
|
||||
- keep `min(120px, 9vw)` behavior when collapsed
|
||||
|
||||
### 6. HermesWorld landing hero was shifted back toward the old glowing-orb vibe
|
||||
Eric preferred the older orb-based entry/startup vibe over the framed screenshot-heavy top fold.
|
||||
|
||||
Action taken:
|
||||
- hero switched to use `PlaygroundHeroCanvas`
|
||||
- kept new landing structure/copy, but moved the visual style back toward orb glow / portal / startup energy
|
||||
|
||||
Touched:
|
||||
- `src/screens/playground/hermes-world-landing.tsx`
|
||||
- `src/screens/playground/components/playground-hero-canvas.tsx` (used, not necessarily modified)
|
||||
|
||||
### 7. HermesWorld copy cleanup
|
||||
Removed more workspace-local carryover text from the landing.
|
||||
|
||||
Examples changed:
|
||||
- `Hermes Workspace Experiment // Persistent Agent World`
|
||||
→ `HermesWorld Preview // Persistent Agent World`
|
||||
- `Hermes Workspace Connected`
|
||||
→ `Live World Build`
|
||||
|
||||
### 8. Local preview/dev process debugging happened repeatedly
|
||||
Root cause for a lot of weirdness:
|
||||
- multiple Vite dev servers were fighting over `routeTree.gen.ts`
|
||||
- this caused reload thrash, aborted requests, and “stopping mid-turn” feelings during local preview
|
||||
|
||||
Key reality:
|
||||
- 3002/3003 reliability was not just “the site is broken”, it was also local dev process instability
|
||||
- detached/nohup relaunches were used at points to keep things alive longer
|
||||
|
||||
### 9. Character/graphics pipeline scaffolding started in code
|
||||
This was the first real gameplay/visual upgrade setup work.
|
||||
|
||||
Added:
|
||||
- `src/screens/playground/lib/character-config.ts`
|
||||
- `src/screens/playground/components/player-character.tsx`
|
||||
- `src/screens/playground/components/npc-character.tsx`
|
||||
- `src/screens/playground/components/playground-glb-body.tsx`
|
||||
- `src/screens/playground/components/playground-player-glb.tsx`
|
||||
- rewrote/normalized `src/screens/playground/components/playground-npc-glb.tsx`
|
||||
- `public/assets/hermesworld/characters/README.md`
|
||||
|
||||
What this means:
|
||||
- canonical asset path now exists for believable humanoid GLBs
|
||||
- canonical path:
|
||||
- `/public/assets/hermesworld/characters/<id>.glb`
|
||||
- legacy `/avatars-3d/<id>.glb` remains as fallback
|
||||
|
||||
### 10. Execution docs/specs were created
|
||||
Created / updated:
|
||||
- `docs/hermesworld/landing-page-spec.md`
|
||||
- `docs/hermesworld/graphics-usability-plan.md`
|
||||
- `docs/hermesworld/agora-believable-checklist.md`
|
||||
|
||||
These docs now encode the 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.
|
||||
49
memory/swarm/missions/2026-05-05-pr-triage.md
Normal file
49
memory/swarm/missions/2026-05-05-pr-triage.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# PR Triage Mission — 2026-05-05
|
||||
|
||||
**Repo:** outsourc-e/hermes-workspace
|
||||
**Goal:** triage all 19 open PRs, produce structured findings per PR, NO AUTO-MERGE.
|
||||
**Reporter:** Aurora (main session)
|
||||
**Lead time:** within this session (~30-45 min for first reports back)
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Do NOT merge any PR.** Eric must approve every merge personally.
|
||||
2. **Do NOT push to main or any contributor branch.**
|
||||
3. **Do NOT close PRs.** Just classify them.
|
||||
4. Report back to Aurora with a single concrete checkpoint per PR.
|
||||
5. If you encounter conflicts, list them; do not resolve them on the PR branch.
|
||||
|
||||
## Per-PR deliverable
|
||||
|
||||
For each assigned PR, produce one structured block:
|
||||
|
||||
```
|
||||
PR #<num> — <title>
|
||||
AUTHOR: <login>
|
||||
CLASSIFICATION: APPROVE_READY | NEEDS_FIX | NEEDS_DISCUSSION | CLOSE_RECOMMEND
|
||||
DIFF_SUMMARY: <2-3 lines, what it actually changes>
|
||||
FILES_TOUCHED: <count + key paths>
|
||||
RISK: low | medium | high — <one-line reason>
|
||||
TESTS_RUN: <pnpm build / pnpm lint / specific test files / N/A>
|
||||
TEST_RESULT: pass | fail | not-run — <evidence>
|
||||
CONFLICTS: none | <files>
|
||||
BLOCKERS: <or none>
|
||||
REVIEW_NOTES: <2-4 lines — concrete things Eric should know before merging>
|
||||
RECOMMENDED_ACTION: merge | request-changes:<what> | close:<why> | discuss:<what>
|
||||
```
|
||||
|
||||
## Lane assignments
|
||||
|
||||
Healthy workers and their PR slices (by PR number):
|
||||
|
||||
- **swarm3** → 338, 336, 335, 334
|
||||
- **swarm4** → 332, 330, 327, 325
|
||||
- **swarm6** → 324, 322, 320, 318
|
||||
- **swarm10** → 317, 315, 310, 308
|
||||
- **swarm12** → 307, 301, 299
|
||||
|
||||
Quarantined: swarm1 (broken runtime), swarm2/swarm11 (rate-limited 429).
|
||||
|
||||
## Checkpoint cadence
|
||||
|
||||
Workers must produce one checkpoint per PR and report it back via STATE: HANDOFF blocks. Aurora will collect, summarize for Eric, and Eric decides each merge.
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
39
public/assets/hermesworld/characters/README.md
Normal file
39
public/assets/hermesworld/characters/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# HermesWorld Character Assets
|
||||
|
||||
Drop character GLBs here.
|
||||
|
||||
## Canonical path
|
||||
|
||||
Characters should live at:
|
||||
|
||||
- `/public/assets/hermesworld/characters/<id>.glb`
|
||||
|
||||
Examples:
|
||||
- `player-adventurer.glb`
|
||||
- `oracle-scholar.glb`
|
||||
- `forge-blacksmith.glb`
|
||||
- `guard-knight.glb`
|
||||
- `merchant-villager.glb`
|
||||
- `villager-common.glb`
|
||||
|
||||
## Source pipeline
|
||||
|
||||
Recommended source order:
|
||||
1. Ready Player Me or similar believable humanoid base
|
||||
2. Mixamo animation clips
|
||||
3. GLB export
|
||||
4. browser optimization
|
||||
|
||||
## First animations to support
|
||||
- idle
|
||||
- walk
|
||||
- run
|
||||
- talk
|
||||
- inspect
|
||||
- use
|
||||
|
||||
## Naming rules
|
||||
- lowercase kebab-case ids
|
||||
- keep one archetype per file
|
||||
- prefer shared rigs
|
||||
- keep texture/material counts low
|
||||
47
public/hermesworld-logo.svg
Normal file
47
public/hermesworld-logo.svg
Normal file
@@ -0,0 +1,47 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" rx="224" fill="#07080D"/>
|
||||
<rect x="42" y="42" width="940" height="940" rx="190" fill="url(#bg)" stroke="url(#border)" stroke-width="12"/>
|
||||
<circle cx="512" cy="512" r="286" fill="#0B1118" stroke="url(#ring)" stroke-width="18"/>
|
||||
<circle cx="512" cy="512" r="220" stroke="#22D3EE" stroke-opacity="0.26" stroke-width="8" stroke-dasharray="28 22"/>
|
||||
<path d="M512 238C585 320 633 394 633 491C633 604 575 681 512 776C449 681 391 604 391 491C391 394 439 320 512 238Z" fill="url(#portal)" stroke="#F8E4AC" stroke-width="12" stroke-linejoin="round"/>
|
||||
<path d="M512 345C557 401 582 447 582 505C582 570 552 619 512 678C472 619 442 570 442 505C442 447 467 401 512 345Z" fill="#071018" stroke="#22D3EE" stroke-width="10" stroke-linejoin="round"/>
|
||||
<path d="M512 301V716" stroke="#F8E4AC" stroke-width="18" stroke-linecap="round"/>
|
||||
<path d="M454 462C488 438 536 438 570 462" stroke="#F8E4AC" stroke-width="16" stroke-linecap="round"/>
|
||||
<path d="M454 532C488 556 536 556 570 532" stroke="#F8E4AC" stroke-width="16" stroke-linecap="round"/>
|
||||
<path d="M371 371C286 346 211 374 151 455C246 458 323 438 383 393L371 371Z" fill="url(#wingL)" stroke="#F8E4AC" stroke-width="10" stroke-linejoin="round"/>
|
||||
<path d="M653 371C738 346 813 374 873 455C778 458 701 438 641 393L653 371Z" fill="url(#wingR)" stroke="#F8E4AC" stroke-width="10" stroke-linejoin="round"/>
|
||||
<path d="M179 431C250 420 306 397 358 360" stroke="#071018" stroke-width="14" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M845 431C774 420 718 397 666 360" stroke="#071018" stroke-width="14" stroke-linecap="round" opacity="0.58"/>
|
||||
<path d="M354 658L512 746L670 658" stroke="#22D3EE" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
|
||||
<circle cx="512" cy="512" r="36" fill="#F8E4AC" stroke="#07080D" stroke-width="10"/>
|
||||
<circle cx="512" cy="512" r="18" fill="#22D3EE"/>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="128" y1="76" x2="896" y2="958" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#10151F"/>
|
||||
<stop offset="0.48" stop-color="#080B12"/>
|
||||
<stop offset="1" stop-color="#050608"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="border" x1="140" y1="80" x2="880" y2="944" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F8E4AC" stop-opacity="0.75"/>
|
||||
<stop offset="0.45" stop-color="#22D3EE" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#A78BFA" stop-opacity="0.45"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ring" x1="276" y1="222" x2="760" y2="814" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F8E4AC"/>
|
||||
<stop offset="1" stop-color="#22D3EE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="portal" x1="512" y1="238" x2="512" y2="776" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F8E4AC"/>
|
||||
<stop offset="0.45" stop-color="#D9B35F"/>
|
||||
<stop offset="1" stop-color="#22D3EE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wingL" x1="149" y1="350" x2="388" y2="457" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22D3EE"/>
|
||||
<stop offset="1" stop-color="#F8E4AC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wingR" x1="875" y1="350" x2="636" y2="457" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22D3EE"/>
|
||||
<stop offset="1" stop-color="#F8E4AC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/hermesworld-logos-reference.png
Normal file
BIN
public/hermesworld-logos-reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/hermesworld-world.png
Normal file
BIN
public/hermesworld-world.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
screenshots/hermes-world-landing-pass.png
Normal file
BIN
screenshots/hermes-world-landing-pass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
54
src/routes/api/playground-admin.ts
Normal file
54
src/routes/api/playground-admin.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
function workerBaseUrl() {
|
||||
const explicit = (process.env.PLAYGROUND_ADMIN_BASE_URL || '').trim()
|
||||
if (explicit) return explicit.replace(/\/+$/, '')
|
||||
const statsUrl = (process.env.VITE_PLAYGROUND_STATS_URL || '').trim()
|
||||
if (statsUrl) return statsUrl.replace(/\/stats$/, '').replace(/\/+$/, '')
|
||||
return 'https://hermes-playground-ws.myaurora-agi.workers.dev'
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/api/playground-admin')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const host = (request.headers.get('host') || '').toLowerCase()
|
||||
const localOk = host.startsWith('127.0.0.1:') || host.startsWith('localhost:') || host.endsWith('.local:3002')
|
||||
if (!localOk) {
|
||||
return json({ ok: false, error: 'Admin stats are only available from a local workspace session.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const token = (process.env.PLAYGROUND_ADMIN_TOKEN || '').trim()
|
||||
if (!token) {
|
||||
return json({ ok: false, error: 'PLAYGROUND_ADMIN_TOKEN is not configured.' }, { status: 503 })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${workerBaseUrl()}/admin/stats`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
return json(
|
||||
{ ok: false, error: `Worker admin request failed (${res.status}): ${text.slice(0, 300)}` },
|
||||
{ status: res.status },
|
||||
)
|
||||
}
|
||||
return new Response(text, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
return json({ ok: false, error: error?.message || 'Unknown error' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
13
src/routes/hermes-world.tsx
Normal file
13
src/routes/hermes-world.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
import { HermesWorldLanding } from '@/screens/playground/hermes-world-landing'
|
||||
|
||||
export const Route = createFileRoute('/hermes-world')({
|
||||
ssr: false,
|
||||
component: HermesWorldRoute,
|
||||
})
|
||||
|
||||
function HermesWorldRoute() {
|
||||
usePageTitle('HermesWorld — AI Agent RPG')
|
||||
return <HermesWorldLanding />
|
||||
}
|
||||
13
src/routes/world.tsx
Normal file
13
src/routes/world.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { usePageTitle } from '@/hooks/use-page-title'
|
||||
import { HermesWorldLanding } from '@/screens/playground/hermes-world-landing'
|
||||
|
||||
export const Route = createFileRoute('/world')({
|
||||
ssr: false,
|
||||
component: WorldRoute,
|
||||
})
|
||||
|
||||
function WorldRoute() {
|
||||
usePageTitle('HermesWorld — AI Agent RPG')
|
||||
return <HermesWorldLanding />
|
||||
}
|
||||
57
src/screens/playground/components/npc-character.tsx
Normal file
57
src/screens/playground/components/npc-character.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { GroupProps } from '@react-three/fiber'
|
||||
import type { CharacterArchetypeId } from '../lib/character-config'
|
||||
import { HERMESWORLD_CHARACTER_ARCHETYPES } from '../lib/character-config'
|
||||
|
||||
type NpcCharacterProps = GroupProps & {
|
||||
archetypeId: CharacterArchetypeId
|
||||
accent?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary visual scaffold for future GLB NPCs.
|
||||
*
|
||||
* This gives us a clean component boundary so Agora can stop rendering every
|
||||
* character ad hoc inside the giant world scene file.
|
||||
*/
|
||||
export function NpcCharacter({ archetypeId, accent, ...props }: NpcCharacterProps) {
|
||||
const archetype =
|
||||
HERMESWORLD_CHARACTER_ARCHETYPES.find((entry) => entry.id === archetypeId) ??
|
||||
HERMESWORLD_CHARACTER_ARCHETYPES[0]
|
||||
|
||||
const tint = accent ?? inferTint(archetypeId)
|
||||
|
||||
return (
|
||||
<group {...props}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.85, 0]}>
|
||||
<capsuleGeometry args={[0.25, 1.0, 6, 12]} />
|
||||
<meshStandardMaterial color={tint} roughness={0.62} metalness={0.06} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 1.75, 0]}>
|
||||
<sphereGeometry args={[0.22, 20, 20]} />
|
||||
<meshStandardMaterial color="#f1c9a5" roughness={0.72} metalness={0.02} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.2, 0]}>
|
||||
<boxGeometry args={[0.84, 0.06, 0.06]} />
|
||||
<meshStandardMaterial color="#d9b35f" emissive="#2c2110" emissiveIntensity={0.14} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function inferTint(archetypeId: CharacterArchetypeId): string {
|
||||
switch (archetypeId) {
|
||||
case 'oracle-scholar':
|
||||
return '#8b6cff'
|
||||
case 'forge-blacksmith':
|
||||
return '#d97745'
|
||||
case 'guard-knight':
|
||||
return '#4773d6'
|
||||
case 'merchant-villager':
|
||||
return '#7c9b57'
|
||||
case 'villager-common':
|
||||
return '#6f8aa8'
|
||||
case 'player-adventurer':
|
||||
default:
|
||||
return '#4b7bec'
|
||||
}
|
||||
}
|
||||
32
src/screens/playground/components/player-character.tsx
Normal file
32
src/screens/playground/components/player-character.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { GroupProps } from '@react-three/fiber'
|
||||
import { HERMESWORLD_CHARACTER_ARCHETYPES } from '../lib/character-config'
|
||||
|
||||
const PLAYER_ARCHETYPE = HERMESWORLD_CHARACTER_ARCHETYPES.find(
|
||||
(entry) => entry.id === 'player-adventurer',
|
||||
)
|
||||
|
||||
/**
|
||||
* Placeholder component for the first believable-player pipeline.
|
||||
*
|
||||
* Intentionally simple for now: we want a stable integration point before
|
||||
* wiring a real GLB + animation controller. The next pass should replace this
|
||||
* with a loaded character model + idle/walk/run/talk animation states.
|
||||
*/
|
||||
export function PlayerCharacter(props: GroupProps) {
|
||||
return (
|
||||
<group {...props}>
|
||||
<mesh castShadow receiveShadow position={[0, 0.9, 0]}>
|
||||
<capsuleGeometry args={[0.28, 1.1, 6, 12]} />
|
||||
<meshStandardMaterial color="#4b7bec" roughness={0.58} metalness={0.08} />
|
||||
</mesh>
|
||||
<mesh castShadow position={[0, 1.85, 0]}>
|
||||
<sphereGeometry args={[0.24, 24, 24]} />
|
||||
<meshStandardMaterial color="#f1c9a5" roughness={0.72} metalness={0.02} />
|
||||
</mesh>
|
||||
<mesh position={[0, 2.35, 0]}>
|
||||
<boxGeometry args={[0.9, 0.08, 0.08]} />
|
||||
<meshStandardMaterial color="#d9b35f" emissive="#3a2b11" emissiveIntensity={0.18} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
261
src/screens/playground/components/playground-admin-panel.tsx
Normal file
261
src/screens/playground/components/playground-admin-panel.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type AdminStats = {
|
||||
ok?: boolean
|
||||
error?: string
|
||||
online: number
|
||||
byWorld: Record<string, number>
|
||||
peakToday: number
|
||||
uniqueToday: number
|
||||
joinsToday: number
|
||||
leavesToday: number
|
||||
chatsToday: number
|
||||
activeLast15m: number
|
||||
activeLast60m: number
|
||||
recentPlayers: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
firstSeen: number
|
||||
lastSeen: number
|
||||
lastWorld?: string
|
||||
lastChatAt?: number
|
||||
chats: number
|
||||
joins: number
|
||||
}>
|
||||
recentEvents: Array<{
|
||||
type: string
|
||||
id: string
|
||||
name?: string
|
||||
color?: string
|
||||
world?: string
|
||||
text?: string
|
||||
ts: number
|
||||
}>
|
||||
ts: number
|
||||
}
|
||||
|
||||
const WORLD_LABELS: Record<string, string> = {
|
||||
training: 'Training',
|
||||
agora: 'Agora',
|
||||
forge: 'Forge',
|
||||
grove: 'Grove',
|
||||
oracle: 'Oracle',
|
||||
arena: 'Arena',
|
||||
}
|
||||
|
||||
const EVENT_STYLES: Record<string, { label: string; tone: string }> = {
|
||||
join: { label: 'Join', tone: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100' },
|
||||
leave: { label: 'Leave', tone: 'border-zinc-300/20 bg-white/5 text-zinc-200' },
|
||||
chat: { label: 'Human chat', tone: 'border-cyan-300/25 bg-cyan-300/10 text-cyan-100' },
|
||||
world_change: { label: 'Travel', tone: 'border-violet-300/25 bg-violet-300/10 text-violet-100' },
|
||||
}
|
||||
|
||||
function fmtTime(ts?: number) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmtAge(ts?: number) {
|
||||
if (!ts) return '—'
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
export function PlaygroundAdminPanel() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/playground-admin', { cache: 'no-store' })
|
||||
const data = (await r.json()) as AdminStats
|
||||
if (!r.ok || data?.ok === false) throw new Error(data?.error || `HTTP ${r.status}`)
|
||||
if (!cancelled) {
|
||||
setStats(data)
|
||||
setError(null)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || 'Failed to load admin stats')
|
||||
}
|
||||
}
|
||||
load()
|
||||
const id = window.setInterval(load, 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const derived = useMemo(() => {
|
||||
if (!stats) return null
|
||||
const recentChatters = stats.recentPlayers.filter((player) => player.chats > 0)
|
||||
const churn = stats.joinsToday > 0 ? Math.round((stats.leavesToday / stats.joinsToday) * 100) : 0
|
||||
const stale = stats.activeLast15m > Math.max(stats.online + 3, stats.online * 2)
|
||||
const busiestWorld = Object.entries(stats.byWorld).sort((a, b) => b[1] - a[1])[0]
|
||||
return { recentChatters, churn, stale, busiestWorld }
|
||||
}, [stats])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed right-3 top-3 z-[90] flex max-h-[calc(100vh-24px)] w-[min(460px,calc(100vw-24px))] flex-col overflow-hidden rounded-3xl border border-amber-200/15 bg-[#07080d]/88 text-xs text-white shadow-[0_24px_80px_rgba(0,0,0,.62)] backdrop-blur-2xl">
|
||||
<div className="border-b border-white/10 bg-gradient-to-r from-amber-300/10 via-cyan-300/8 to-violet-300/10 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-amber-200/25 bg-amber-200/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.18em] text-amber-100">Private</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] text-white/45">Dashboard admin</span>
|
||||
</div>
|
||||
<div className="mt-1 text-base font-bold tracking-tight text-white">HermesWorld Control Room</div>
|
||||
<div className="mt-0.5 text-[11px] text-white/50">Human relay analytics. NPC ambient chatter is client-side flavor and intentionally excluded.</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-white/45">
|
||||
<div>Updated</div>
|
||||
<div className="font-semibold text-white/70">{fmtTime(stats?.ts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-4">
|
||||
{error ? <div className="rounded-2xl border border-red-400/25 bg-red-500/10 p-3 text-red-100">{error}</div> : null}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard label="Online now" value={stats.online} accent="#34d399" />
|
||||
<StatCard label="Unique today" value={stats.uniqueToday} accent="#fbbf24" />
|
||||
<StatCard label="Peak today" value={stats.peakToday} accent="#a78bfa" />
|
||||
<StatCard label="Active 15m" value={stats.activeLast15m} accent="#22d3ee" />
|
||||
<StatCard label="Active 60m" value={stats.activeLast60m} accent="#60a5fa" />
|
||||
<StatCard label="Human chats" value={stats.chatsToday} accent="#f472b6" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<HealthPill label="Joins" value={stats.joinsToday} />
|
||||
<HealthPill label="Leaves" value={stats.leavesToday} />
|
||||
<HealthPill label="Churn" value={`${derived?.churn ?? 0}%`} warn={(derived?.churn ?? 0) > 75 && stats.joinsToday > 5} />
|
||||
</div>
|
||||
|
||||
{derived?.stale ? (
|
||||
<div className="rounded-2xl border border-yellow-300/25 bg-yellow-300/10 p-3 text-[11px] text-yellow-100">
|
||||
Active players are much higher than live sockets. Likely reconnect/background-tab churn, not real concurrent users.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Worlds" detail={derived?.busiestWorld ? `Busiest: ${WORLD_LABELS[derived.busiestWorld[0]] ?? derived.busiestWorld[0]}` : 'No live world yet'} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries({ training: 0, agora: 0, forge: 0, grove: 0, oracle: 0, arena: 0, ...stats.byWorld }).map(([world, count]) => (
|
||||
<div key={world} className="rounded-2xl border border-white/8 bg-white/[0.045] px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-white/80">{WORLD_LABELS[world] ?? world}</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] text-white/65">{count}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-cyan-300/80" style={{ width: `${Math.min(100, count * 18)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent players" detail={`${stats.recentPlayers.length} tracked today`} />
|
||||
<div className="max-h-56 space-y-1.5 overflow-auto rounded-2xl border border-white/8 bg-black/25 p-2">
|
||||
{stats.recentPlayers.length === 0 ? <EmptyState label="No human players tracked yet." /> : null}
|
||||
{stats.recentPlayers.slice(0, 14).map((player) => (
|
||||
<div key={player.id} className="grid grid-cols-[1fr_auto] items-center gap-2 rounded-xl bg-white/[0.04] px-2.5 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: player.color || '#fff' }} />
|
||||
<span className="truncate font-semibold" style={{ color: player.color || '#fff' }}>{player.name || player.id.slice(0, 8)}</span>
|
||||
{player.chats > 0 ? <span className="rounded bg-cyan-300/12 px-1.5 py-0.5 text-[9px] uppercase tracking-[0.12em] text-cyan-100">chatter</span> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-white/45">
|
||||
{WORLD_LABELS[player.lastWorld || ''] ?? player.lastWorld ?? 'unknown'} · {fmtAge(player.lastSeen)} · joined {player.joins}x · chats {player.chats}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-white/42">
|
||||
<div>last chat</div>
|
||||
<div className="text-white/65">{fmtAge(player.lastChatAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent human chatters" detail={`${derived?.recentChatters.length ?? 0} today`} />
|
||||
<div className="flex flex-wrap gap-1.5 rounded-2xl border border-white/8 bg-white/[0.035] p-2">
|
||||
{derived?.recentChatters.length === 0 ? <span className="text-[11px] text-white/40">No human chat yet. NPC bubbles are not counted here.</span> : null}
|
||||
{derived?.recentChatters.slice(0, 12).map((player) => (
|
||||
<span key={player.id} className="rounded-full border border-cyan-300/15 bg-cyan-300/8 px-2 py-1 text-[10px] text-cyan-50">
|
||||
{player.name || player.id.slice(0, 8)} · {player.chats}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle title="Recent events" detail="latest relay events" />
|
||||
<div className="max-h-64 space-y-1.5 overflow-auto rounded-2xl border border-white/8 bg-black/25 p-2">
|
||||
{stats.recentEvents.length === 0 ? <EmptyState label="No relay events yet." /> : null}
|
||||
{stats.recentEvents.slice(0, 28).map((event, idx) => {
|
||||
const style = EVENT_STYLES[event.type] ?? { label: event.type, tone: 'border-white/15 bg-white/8 text-white/80' }
|
||||
return (
|
||||
<div key={`${event.ts}-${event.id}-${idx}`} className="rounded-xl bg-white/[0.035] px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] ${style.tone}`}>{style.label}</span>
|
||||
<span className="text-[10px] text-white/40">{fmtTime(event.ts)}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-white/70">
|
||||
<span style={{ color: event.color || undefined }}>{event.name || event.id.slice(0, 8)}</span>
|
||||
{event.world ? <span className="text-white/40"> · {WORLD_LABELS[event.world] ?? event.world}</span> : null}
|
||||
{event.text ? <span className="text-white/55"> · “{event.text}”</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ title, detail }: { title: string; detail?: string }) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-end justify-between gap-2">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-white/48">{title}</div>
|
||||
{detail ? <div className="truncate text-[10px] text-white/35">{detail}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: number; accent: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/[0.045] px-3 py-2.5 shadow-inner shadow-white/[0.02]">
|
||||
<div className="text-[9px] font-bold uppercase tracking-[0.13em] text-white/42">{label}</div>
|
||||
<div className="mt-1 text-2xl font-black leading-none" style={{ color: accent }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthPill({ label, value, warn = false }: { label: string; value: number | string; warn?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-2xl border px-3 py-2 ${warn ? 'border-yellow-300/25 bg-yellow-300/10' : 'border-white/8 bg-white/[0.04]'}`}>
|
||||
<div className="text-[9px] uppercase tracking-[0.13em] text-white/42">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-bold text-white">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return <div className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-[11px] text-white/38">{label}</div>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
113
src/screens/playground/components/playground-glb-body.tsx
Normal file
113
src/screens/playground/components/playground-glb-body.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Component, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useGLTF } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
|
||||
class GlbErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError?: () => void },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
componentDidCatch() {
|
||||
this.props.onError?.()
|
||||
}
|
||||
render() {
|
||||
if (this.state.failed) return null
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const probeCache = new Map<string, 'unknown' | 'present' | 'missing'>()
|
||||
|
||||
export function useGlbProbe(url: string): 'unknown' | 'present' | 'missing' {
|
||||
const [state, setState] = useState<'unknown' | 'present' | 'missing'>(
|
||||
() => probeCache.get(url) || 'unknown',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (probeCache.get(url) === 'present' || probeCache.get(url) === 'missing') {
|
||||
setState(probeCache.get(url)!)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
const isReal =
|
||||
r.ok &&
|
||||
!ct.includes('text/html') &&
|
||||
(ct.includes('octet-stream') ||
|
||||
ct.includes('gltf') ||
|
||||
ct.includes('binary') ||
|
||||
ct === '' ||
|
||||
ct.includes('application/'))
|
||||
const v = isReal ? 'present' : 'missing'
|
||||
probeCache.set(url, v)
|
||||
setState(v)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
probeCache.set(url, 'missing')
|
||||
setState('missing')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [url])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function GlbInner({ url, scale, yOffset }: { url: string; scale: number; yOffset: number }) {
|
||||
const { scene } = useGLTF(url) as any
|
||||
const ref = useRef<THREE.Group>(null)
|
||||
const cloned = useMemo(() => {
|
||||
const s = (scene as THREE.Object3D).clone(true)
|
||||
s.traverse((obj: any) => {
|
||||
if (obj.isMesh) {
|
||||
obj.castShadow = true
|
||||
obj.receiveShadow = false
|
||||
obj.raycast = () => {}
|
||||
if (obj.material && obj.material.map) {
|
||||
obj.material.map.anisotropy = 4
|
||||
}
|
||||
}
|
||||
})
|
||||
return s
|
||||
}, [scene])
|
||||
|
||||
return (
|
||||
<group ref={ref} position={[0, yOffset, 0]} scale={scale}>
|
||||
<primitive object={cloned} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export function OptionalGlbBody({
|
||||
url,
|
||||
scale = 1,
|
||||
yOffset = 0,
|
||||
}: {
|
||||
url: string
|
||||
scale?: number
|
||||
yOffset?: number
|
||||
}) {
|
||||
const status = useGlbProbe(url)
|
||||
const [hardFailed, setHardFailed] = useState(false)
|
||||
if (status !== 'present' || hardFailed) return null
|
||||
return (
|
||||
<GlbErrorBoundary
|
||||
onError={() => {
|
||||
probeCache.set(url, 'missing')
|
||||
setHardFailed(true)
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<GlbInner url={url} scale={scale} yOffset={yOffset} />
|
||||
</Suspense>
|
||||
</GlbErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/screens/playground/components/playground-player-glb.tsx
Normal file
26
src/screens/playground/components/playground-player-glb.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { OptionalGlbBody } from './playground-glb-body'
|
||||
|
||||
type PlaygroundPlayerGlbProps = {
|
||||
avatarId?: string
|
||||
}
|
||||
|
||||
function candidateUrls(avatarId?: string): string[] {
|
||||
const id = (avatarId || 'player-adventurer').trim()
|
||||
const safe = id.replace(/[^a-z0-9_-]+/gi, '') || 'player-adventurer'
|
||||
return [
|
||||
`/assets/hermesworld/characters/${safe}.glb`,
|
||||
'/assets/hermesworld/characters/player-adventurer.glb',
|
||||
`/avatars-3d/${safe}.glb`,
|
||||
]
|
||||
}
|
||||
|
||||
export function PlaygroundPlayerGlb({ avatarId }: PlaygroundPlayerGlbProps) {
|
||||
const urls = candidateUrls(avatarId)
|
||||
return (
|
||||
<>
|
||||
{urls.map((url) => (
|
||||
<OptionalGlbBody key={url} url={url} scale={0.92} yOffset={0} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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' && (
|
||||
|
||||
447
src/screens/playground/hermes-world-landing.tsx
Normal file
447
src/screens/playground/hermes-world-landing.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { PlaygroundHeroCanvas } from './components/playground-hero-canvas'
|
||||
|
||||
const HERMES_REPO_URL = 'https://github.com/outsourc-e/hermes-workspace'
|
||||
const HERMES_ROADMAP_URL = 'https://github.com/outsourc-e/hermes-workspace/blob/main/docs/hermesworld/master-roadmap.md'
|
||||
const HERMES_FEATURES_URL = 'https://github.com/outsourc-e/hermes-workspace/blob/main/FEATURES-INVENTORY.md'
|
||||
|
||||
const externalLinkProps = { target: '_blank', rel: 'noreferrer' }
|
||||
|
||||
const capabilities = [
|
||||
{ label: 'Persistent World', copy: 'World state keeps moving while agents continue work.', icon: '✦' },
|
||||
{ label: 'Live Agents', copy: 'Companions follow, plan, craft, scout, and report back.', icon: '◈' },
|
||||
{ label: 'Zones & Quests', copy: 'Each zone gives humans and agents a place to act.', icon: '◇' },
|
||||
{ label: 'Memory Progression', copy: 'Agents carry context, unlocks, and completed history.', icon: '◎' },
|
||||
{ label: 'Hermes Sigils', copy: 'Make invisible agent progress visible and collectible.', icon: '⚚' },
|
||||
{ label: 'Multiplayer Presence', copy: 'Humans and agents share the same world layer.', icon: '◌' },
|
||||
]
|
||||
|
||||
const zones = [
|
||||
{
|
||||
name: 'Training Grounds',
|
||||
label: 'Starter Zone',
|
||||
tone: '#76d88f',
|
||||
copy: 'Learn the verbs of the world. Move, talk, equip, travel, and send your first companion on a quest.',
|
||||
},
|
||||
{
|
||||
name: 'Forge',
|
||||
label: 'Progression',
|
||||
tone: '#ff9f55',
|
||||
copy: 'Craft tools for agents. Upgrade companions, shape items, and turn raw progress into better workflows.',
|
||||
},
|
||||
{
|
||||
name: 'Agora',
|
||||
label: 'Social Hub',
|
||||
tone: '#d9b35f',
|
||||
copy: 'The social relay. Meet NPCs, inspect public quests, and watch live activity from humans and agents.',
|
||||
},
|
||||
{
|
||||
name: 'Grove',
|
||||
label: 'Memory Zone',
|
||||
tone: '#34d399',
|
||||
copy: 'A quieter zone for long-term memory, reflection, archived quests, and restoring agent energy.',
|
||||
},
|
||||
{
|
||||
name: 'Oracle',
|
||||
label: 'Planning',
|
||||
tone: '#a78bfa',
|
||||
copy: 'Planning and prophecy. Decompose goals, reveal quest paths, and route work to the right agent.',
|
||||
},
|
||||
{
|
||||
name: 'Arena',
|
||||
label: 'Combat Online',
|
||||
tone: '#f87171',
|
||||
copy: 'Battle, evals, and trials. Test agents in controlled challenges and unlock capabilities through risk.',
|
||||
},
|
||||
]
|
||||
|
||||
const party = [
|
||||
{ name: 'Atlas', role: 'Scout', state: 'Following', tone: '#76d88f' },
|
||||
{ name: 'Forge', role: 'Builder', state: 'Crafting', tone: '#ff9f55' },
|
||||
{ name: 'Oracle', role: 'Planner', state: 'Planning', tone: '#a78bfa' },
|
||||
]
|
||||
|
||||
const consoleLines = [
|
||||
'move_to("Agora")',
|
||||
'talk_to("Quartermaster")',
|
||||
'accept_quest("Northern Gate")',
|
||||
'equip("Hermes Sigil")',
|
||||
'send_agent("Oracle", "plan route")',
|
||||
'complete_quest("First Step")',
|
||||
]
|
||||
|
||||
const progression = [
|
||||
['Unlocks', 'Open zones, panes, capabilities, and world systems.'],
|
||||
['Agent Progression', 'Upgrade companion abilities, tools, loadouts, and memory depth.'],
|
||||
['Quests', 'Convert goals into trackable work with receipts and history.'],
|
||||
['Cosmetics & Lore', 'Customize player, companions, banners, and world profile.'],
|
||||
]
|
||||
|
||||
const todayDrops = [
|
||||
['Landing Page', 'A cinematic share surface for the HermesWorld reveal.'],
|
||||
['Roadmap', 'The build path from preview world to persistent agent RPG.'],
|
||||
['Feature List', 'Zones, companions, quests, sigils, multiplayer, and world systems.'],
|
||||
['Graphics Sprint', 'Next pass: in-game world art, logo lockup, and launch visuals.'],
|
||||
]
|
||||
|
||||
export function HermesWorldLanding() {
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[#03060a] text-[#f8f3e7] selection:bg-[#d9b35f] selection:text-[#07080d]">
|
||||
<HermesBackdrop />
|
||||
<Header />
|
||||
|
||||
<section className="relative mx-auto grid min-h-[calc(100vh-82px)] w-full max-w-[1560px] items-center gap-10 px-4 pb-12 pt-8 sm:px-6 lg:grid-cols-[0.78fr_1.22fr] lg:px-8 lg:pb-18 lg:pt-10">
|
||||
<div className="relative z-10 max-w-2xl lg:pl-2">
|
||||
<div className="mb-5 inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.24em] text-[#f8e4ac] shadow-[0_0_42px_rgba(217,179,95,.12)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-200 shadow-[0_0_18px_rgba(34,211,238,.95)]" />
|
||||
HermesWorld Preview // Persistent Agent World
|
||||
</div>
|
||||
|
||||
<h1 className="max-w-[760px] text-balance font-serif text-[clamp(3.6rem,7.5vw,8.4rem)] leading-[0.82] tracking-[-0.075em] text-[#fff6df] drop-shadow-[0_20px_80px_rgba(0,0,0,.65)]">
|
||||
Your AI workspace is becoming a world.
|
||||
</h1>
|
||||
|
||||
<p className="mt-7 max-w-xl text-pretty text-lg leading-8 text-[#d7d0bd]/72 sm:text-xl">
|
||||
HermesWorld turns agents into companions inside a persistent world. Explore zones, complete quests, collect sigils, and watch agents keep working while the world keeps moving.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<a href="/playground" className="group inline-flex items-center justify-center rounded-xl border border-[#ffe7a3]/50 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#11100b] shadow-[0_24px_80px_rgba(217,179,95,.28)] transition hover:-translate-y-0.5 hover:brightness-110">
|
||||
▶ Play Now <span className="ml-2 transition group-hover:translate-x-1">→</span>
|
||||
</a>
|
||||
<a href={HERMES_REPO_URL} {...externalLinkProps} className="inline-flex items-center justify-center rounded-xl border border-[#d9b35f]/24 bg-[#0b1118]/78 px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#f8e4ac] shadow-[inset_0_1px_0_rgba(255,255,255,.08)] backdrop-blur-xl transition hover:border-[#d9b35f]/50 hover:bg-[#121823]">
|
||||
View on GitHub
|
||||
</a>
|
||||
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="inline-flex items-center justify-center rounded-xl border border-[#d9b35f]/24 bg-[#0b1118]/78 px-6 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#f8e4ac] shadow-[inset_0_1px_0_rgba(255,255,255,.08)] backdrop-blur-xl transition hover:border-[#d9b35f]/50 hover:bg-[#121823]">
|
||||
Read Roadmap
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[11px] font-bold uppercase tracking-[0.16em] text-[#bfb49a]/60">
|
||||
<span>Preview build</span>
|
||||
<span className="text-[#d9b35f]/55">✦</span>
|
||||
<span>Persistent agents</span>
|
||||
<span className="text-[#d9b35f]/55">✦</span>
|
||||
<span>Zones, quests, sigils</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 max-w-xl rounded-2xl border border-[#d9b35f]/18 bg-[#05080e]/70 p-4 shadow-[0_20px_70px_rgba(0,0,0,.28),inset_0_1px_0_rgba(255,255,255,.06)] backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-[#d9b35f]/35 bg-[#d9b35f]/12 text-lg shadow-[0_0_28px_rgba(217,179,95,.16)]">🏆</span>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.22em] text-[#d9b35f]/70">Dropping today</div>
|
||||
<div className="mt-1 text-sm font-bold leading-5 text-[#fff6df]">HermesWorld landing page, roadmap, feature list, and the first public build notes.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeroWorldFrame />
|
||||
</section>
|
||||
|
||||
<CapabilityStrip />
|
||||
<TodayDropSection />
|
||||
<ZonesSection />
|
||||
<AgentsSection />
|
||||
<SigilsSection />
|
||||
<FinalCta />
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<header className="relative z-30 mx-auto mt-4 flex max-w-[1560px] items-center justify-between border-b border-[#d9b35f]/20 px-4 pb-4 sm:px-6 lg:px-8">
|
||||
<a href="/hermes-world" className="flex items-center gap-3">
|
||||
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-10 w-10 rounded-2xl shadow-[0_0_34px_rgba(34,211,238,.18)]" />
|
||||
<div>
|
||||
<div className="font-serif text-lg font-bold tracking-[-0.03em] text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></div>
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.22em] text-[#bfb49a]/46">Persistent Agent RPG</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-[11px] font-black uppercase tracking-[0.18em] text-[#d7d0bd]/58 md:flex">
|
||||
<a href="#world" className="transition hover:text-[#f8e4ac]">World</a>
|
||||
<a href="#agents" className="transition hover:text-[#f8e4ac]">Agents</a>
|
||||
<a href="#sigils" className="transition hover:text-[#f8e4ac]">Sigils</a>
|
||||
<a href="#preview" className="transition hover:text-[#f8e4ac]">Preview</a>
|
||||
<a href="#today" className="transition hover:text-[#f8e4ac]">Today</a>
|
||||
<a href="/playground" className="rounded-lg border border-[#ffe7a3]/55 bg-[linear-gradient(180deg,#ffe7a3,#d9a63f)] px-4 py-2 font-black text-[#11100b] shadow-[0_0_30px_rgba(217,179,95,.18)] transition hover:brightness-110">▶ Play</a>
|
||||
<a href={HERMES_REPO_URL} {...externalLinkProps} className="rounded-lg border border-[#d9b35f]/30 bg-[#d9b35f]/10 px-4 py-2 text-[#f8e4ac] shadow-[0_0_30px_rgba(217,179,95,.08)] transition hover:bg-[#d9b35f]/18">GitHub</a>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroWorldFrame() {
|
||||
return (
|
||||
<div id="preview" className="relative z-10 mx-auto w-full max-w-[930px]">
|
||||
<div className="absolute -inset-10 rounded-[4rem] bg-[radial-gradient(circle_at_50%_40%,rgba(34,211,238,.18),transparent_52%),radial-gradient(circle_at_68%_72%,rgba(167,139,250,.16),transparent_48%)] blur-3xl" />
|
||||
<div className="relative overflow-hidden rounded-[1.65rem] border border-[#d9b35f]/34 bg-[#05080e] p-2 shadow-[0_50px_160px_rgba(0,0,0,.72),0_0_80px_rgba(217,179,95,.12)]">
|
||||
<div className="flex items-center justify-between border-b border-[#d9b35f]/18 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<i className="h-2.5 w-2.5 rounded-full bg-amber-300" />
|
||||
<i className="h-2.5 w-2.5 rounded-full bg-emerald-300" />
|
||||
<span className="ml-3 text-[9px] font-black uppercase tracking-[0.24em] text-[#f8e4ac]/70">Live World Preview</span>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 text-[9px] font-black uppercase tracking-[0.18em] text-emerald-200/75 sm:flex">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(52,211,153,.9)]" />
|
||||
Live World Build
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-[16/10] overflow-hidden rounded-[1.15rem] bg-[#0a1117]">
|
||||
<div className="absolute inset-0">
|
||||
<PlaygroundHeroCanvas />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_42%,transparent_36%,rgba(3,6,10,.28)_78%),linear-gradient(180deg,rgba(3,6,10,.02),rgba(3,6,10,.62))]" />
|
||||
<div className="absolute left-4 top-4 rounded-xl border border-[#d9b35f]/24 bg-[#05080e]/70 px-3 py-2 shadow-2xl backdrop-blur-xl">
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-[#f8e4ac]/70">HermesWorld Entry</div>
|
||||
<div className="mt-1 text-xs text-[#d7d0bd]/70">Portal online · Zones awakening · Sigils active</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 max-w-[320px] rounded-xl border border-cyan-200/20 bg-[#05080e]/72 p-3 shadow-2xl backdrop-blur-xl">
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.18em] text-cyan-100/72">World State</div>
|
||||
<div className="mt-1 text-sm font-bold text-[#fff6df]">Agents need a world they can inhabit.</div>
|
||||
<div className="mt-1 text-xs leading-5 text-[#d7d0bd]/58">Persistent zones, believable characters, cleaner quests, stronger atmosphere.</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 hidden rounded-xl border border-violet-200/20 bg-[#05080e]/70 p-3 text-xs text-[#d7d0bd]/62 shadow-2xl backdrop-blur-xl sm:block">
|
||||
<div className="text-[9px] font-black uppercase tracking-[0.18em] text-violet-100/72">Launch Focus</div>
|
||||
<div className="mt-1">Agora believable</div>
|
||||
<div>Character pipeline</div>
|
||||
<div>Graphics + HUD pass</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CapabilityStrip() {
|
||||
return (
|
||||
<section className="relative mx-auto max-w-[1560px] px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div className="grid overflow-hidden rounded-2xl border border-[#d9b35f]/20 bg-[#071018]/76 shadow-[inset_0_1px_0_rgba(255,255,255,.06),0_30px_100px_rgba(0,0,0,.38)] backdrop-blur-xl sm:grid-cols-2 lg:grid-cols-6">
|
||||
{capabilities.map((capability) => (
|
||||
<div key={capability.label} className="border-b border-r border-[#d9b35f]/12 p-5 last:border-r-0 sm:min-h-36 lg:border-b-0">
|
||||
<div className="mb-3 text-2xl text-[#d9b35f] drop-shadow-[0_0_18px_rgba(217,179,95,.3)]">{capability.icon}</div>
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.17em] text-[#f8e4ac]">{capability.label}</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/52">{capability.copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TodayDropSection() {
|
||||
return (
|
||||
<section id="today" className="relative mx-auto max-w-[1560px] px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden rounded-[2rem] border border-[#d9b35f]/24 bg-[radial-gradient(circle_at_18%_0%,rgba(217,179,95,.2),transparent_34%),linear-gradient(135deg,rgba(7,16,24,.92),rgba(4,7,12,.9))] p-5 shadow-[0_34px_120px_rgba(0,0,0,.42)] sm:p-7 lg:p-8">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.74fr_1.26fr] lg:items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[#d9b35f]/24 bg-[#d9b35f]/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-[0.22em] text-[#f8e4ac]">
|
||||
<span>🏆</span>
|
||||
Launch drop
|
||||
</div>
|
||||
<h2 className="mt-4 font-serif text-4xl font-bold leading-[0.92] tracking-[-0.055em] text-[#fff6df] sm:text-5xl lg:text-6xl">
|
||||
Dropping HermesWorld today.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-sm leading-7 text-[#d7d0bd]/62 sm:text-base">
|
||||
Landing page, roadmap, feature list, and build notes first. Then we move straight into the game graphics sprint: world art, logo lockup, social cards, and clip-ready visuals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{todayDrops.map(([title, copy]) => (
|
||||
<article key={title} className="rounded-2xl border border-white/10 bg-black/22 p-5 shadow-[inset_0_1px_0_rgba(255,255,255,.06)]">
|
||||
<div className="mb-3 h-px w-12 bg-[linear-gradient(90deg,#d9b35f,transparent)]" />
|
||||
<h3 className="text-sm font-black uppercase tracking-[0.18em] text-[#f8e4ac]">{title}</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/54">{copy}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ZonesSection() {
|
||||
return (
|
||||
<section id="world" className="relative mx-auto max-w-[1560px] px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[#d9b35f]/70">The world map</div>
|
||||
<h2 className="mx-auto mt-3 max-w-4xl font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Six zones. One persistent agent world.</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-sm leading-7 text-[#d7d0bd]/58 sm:text-base">Every zone teaches a different part of the agent loop: training, crafting, strategy, memory, prophecy, and evaluation.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
{zones.map((zone, index) => (
|
||||
<article key={zone.name} className="group overflow-hidden rounded-2xl border border-[#d9b35f]/20 bg-[#071018]/82 p-2 shadow-[0_24px_90px_rgba(0,0,0,.38)] transition hover:-translate-y-1 hover:border-[#d9b35f]/38">
|
||||
<div className="relative h-48 overflow-hidden rounded-xl bg-[#0c1415]" style={{ boxShadow: `inset 0 0 100px ${zone.tone}22` }}>
|
||||
<ZoneDiorama index={index} tone={zone.tone} />
|
||||
<div className="absolute left-3 top-3 rounded-md border border-white/10 bg-black/42 px-2 py-1 text-[8px] font-black uppercase tracking-[0.16em] text-white/65 backdrop-blur">{zone.label}</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-serif text-2xl font-bold tracking-[-0.035em]" style={{ color: zone.tone }}>{zone.name}</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/56">{zone.copy}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentsSection() {
|
||||
return (
|
||||
<section id="agents" className="relative border-y border-[#d9b35f]/14 bg-[#061016]/72 px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto grid max-w-[1440px] gap-8 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||
<div>
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-cyan-100/60">Humans + Agents</div>
|
||||
<h2 className="mt-3 max-w-2xl font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Your agents live in the world with you.</h2>
|
||||
<p className="mt-5 max-w-xl text-base leading-8 text-[#d7d0bd]/62">HermesWorld turns agents into visible companions. They can follow, take quests, report progress, and eventually move through the world on their own.</p>
|
||||
<div className="mt-7 grid gap-3 text-sm text-[#d7d0bd]/62 sm:grid-cols-3">
|
||||
<InfoPill title="Companions" copy="Roles, memory, and status." />
|
||||
<InfoPill title="Takeover" copy="Future agent world actions." />
|
||||
<InfoPill title="Offline" copy="Progress continues between visits." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[0.8fr_1fr]">
|
||||
<div className="rounded-2xl border border-[#d9b35f]/18 bg-[#05080e]/78 p-4 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-[#f8e4ac]/62">Your Party</div>
|
||||
<div className="space-y-3">
|
||||
{party.map((agent) => (
|
||||
<div key={agent.name} className="rounded-xl border border-white/8 bg-white/[0.035] p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-[#fff6df]">{agent.name}</div>
|
||||
<div className="text-xs text-[#d7d0bd]/45">{agent.role}</div>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-xl" style={{ background: `${agent.tone}22`, border: `1px solid ${agent.tone}66`, boxShadow: `0 0 22px ${agent.tone}22` }} />
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-white/8 bg-black/28 px-3 py-2 text-xs font-bold" style={{ color: agent.tone }}>{agent.state}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-cyan-100/16 bg-[#04070c]/82 p-4 shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.2em] text-cyan-100/62">Agent Console</div>
|
||||
<div className="min-h-[340px] rounded-xl border border-white/8 bg-black/36 p-4 font-mono text-xs leading-7 text-[#bff9ff]/72">
|
||||
{consoleLines.map((line) => (
|
||||
<div key={line}><span className="text-[#d9b35f]">›</span> {line}</div>
|
||||
))}
|
||||
<div className="mt-3 text-[#76d88f]">quest accepted: Open the Northern Gate</div>
|
||||
<div className="text-[#a78bfa]">agent route planned: Oracle → Grove → Forge</div>
|
||||
<div className="mt-3 animate-pulse text-[#f8e4ac]">▌</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SigilsSection() {
|
||||
return (
|
||||
<section id="sigils" className="relative mx-auto max-w-[1560px] px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 rounded-[2rem] border border-[#d9b35f]/22 bg-[radial-gradient(circle_at_32%_42%,rgba(217,179,95,.22),transparent_34%),#071018] p-5 shadow-[0_40px_140px_rgba(0,0,0,.45)] md:grid-cols-[0.9fr_1.1fr] md:p-8 lg:p-10">
|
||||
<div className="relative flex min-h-[360px] items-center justify-center overflow-hidden rounded-[1.35rem] border border-[#d9b35f]/18 bg-[#04070c]">
|
||||
<div className="absolute h-64 w-64 rounded-full bg-[#d9b35f]/20 blur-3xl" />
|
||||
<div className="relative flex h-56 w-56 items-center justify-center rounded-full border border-[#d9b35f]/42 bg-[radial-gradient(circle,#f8e4ac_0%,#d9b35f_22%,#4b3516_68%,#120d08_100%)] shadow-[0_0_90px_rgba(217,179,95,.32)]">
|
||||
<img src="/hermesworld-logo.svg" alt="Hermes Sigil" className="h-36 w-36 rounded-[2rem] shadow-[0_0_40px_rgba(34,211,238,.18)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[#d9b35f]/72">In-world progression</div>
|
||||
<h2 className="mt-3 font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Collect Hermes Sigils as you unlock the world.</h2>
|
||||
<p className="mt-5 text-base leading-8 text-[#d7d0bd]/62">Hermes Sigils are progression artifacts earned through quests, agent upgrades, world exploration, and system mastery. They make invisible agent progress visible.</p>
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2">
|
||||
{progression.map(([title, copy]) => (
|
||||
<div key={title} className="rounded-xl border border-[#d9b35f]/14 bg-black/20 p-4">
|
||||
<div className="text-sm font-black uppercase tracking-[0.16em] text-[#f8e4ac]">{title}</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#d7d0bd]/54">{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FinalCta() {
|
||||
return (
|
||||
<section className="relative overflow-hidden px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div className="absolute inset-0 -z-10 bg-[linear-gradient(180deg,rgba(3,6,10,.18),#03060a),url('/hermesworld-world.png')] bg-cover bg-center opacity-45" />
|
||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_20%,rgba(217,179,95,.2),transparent_42%),linear-gradient(90deg,#03060a_0%,rgba(3,6,10,.58)_50%,#03060a_100%)]" />
|
||||
<div className="mx-auto max-w-[880px] rounded-[2rem] border border-[#d9b35f]/24 bg-[#05080e]/78 p-8 text-center shadow-[0_40px_140px_rgba(0,0,0,.52)] backdrop-blur-xl sm:p-12">
|
||||
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-cyan-100/62">Enter the world</div>
|
||||
<h2 className="mt-3 font-serif text-4xl font-bold tracking-[-0.055em] text-[#fff6df] sm:text-6xl">Build with agents in a world, not a chat box.</h2>
|
||||
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-[#d7d0bd]/62">Enter HermesWorld and explore the first playable layer of Hermes Workspace: zones, quests, companions, sigils, and persistent agent progression.</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<a href="/playground" className="rounded-xl bg-[#f8e4ac] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-[#11100b] transition hover:-translate-y-0.5 hover:bg-white">▶ Play Now</a>
|
||||
<a href={HERMES_REPO_URL} {...externalLinkProps} className="rounded-xl border border-white/12 bg-white/[0.055] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-white/78 transition hover:bg-white/[0.1]">View GitHub</a>
|
||||
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="rounded-xl border border-white/12 bg-white/[0.055] px-7 py-4 text-sm font-black uppercase tracking-[0.14em] text-white/78 transition hover:bg-white/[0.1]">Read Roadmap</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="mx-auto flex max-w-[1560px] flex-col gap-4 border-t border-[#d9b35f]/14 px-4 py-8 text-xs text-[#d7d0bd]/42 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/hermesworld-logo.svg" alt="HermesWorld" className="h-8 w-8 rounded-xl" />
|
||||
<span className="font-serif text-base text-[#f8e4ac]">Hermes<span className="text-cyan-200">World</span></span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 uppercase tracking-[0.16em]">
|
||||
<a href={HERMES_REPO_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">GitHub</a>
|
||||
<a href={HERMES_ROADMAP_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">Roadmap</a>
|
||||
<a href={HERMES_FEATURES_URL} {...externalLinkProps} className="hover:text-[#f8e4ac]">Feature List</a>
|
||||
<a href="#today" className="hover:text-[#f8e4ac]">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>
|
||||
)
|
||||
}
|
||||
110
src/screens/playground/lib/character-config.ts
Normal file
110
src/screens/playground/lib/character-config.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export type CharacterArchetypeId =
|
||||
| 'player-adventurer'
|
||||
| 'oracle-scholar'
|
||||
| 'forge-blacksmith'
|
||||
| 'guard-knight'
|
||||
| 'merchant-villager'
|
||||
| 'villager-common'
|
||||
|
||||
export type CharacterAnimationClip =
|
||||
| 'idle'
|
||||
| 'walk'
|
||||
| 'run'
|
||||
| 'talk'
|
||||
| 'inspect'
|
||||
| 'use'
|
||||
|
||||
export type CharacterArchetype = {
|
||||
id: CharacterArchetypeId
|
||||
label: string
|
||||
zone: 'agora' | 'oracle' | 'forge' | 'grove' | 'arena' | 'training-grounds'
|
||||
role: 'player' | 'npc'
|
||||
modelPath: string
|
||||
defaultScale: number
|
||||
paletteHint: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export const CHARACTER_ANIMATION_PRIORITY: CharacterAnimationClip[] = [
|
||||
'idle',
|
||||
'walk',
|
||||
'run',
|
||||
'talk',
|
||||
'inspect',
|
||||
'use',
|
||||
]
|
||||
|
||||
export const HERMESWORLD_CHARACTER_ARCHETYPES: CharacterArchetype[] = [
|
||||
{
|
||||
id: 'player-adventurer',
|
||||
label: 'Player Adventurer',
|
||||
zone: 'agora',
|
||||
role: 'player',
|
||||
modelPath: '/assets/hermesworld/characters/player-adventurer.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Blue-gold hero silhouette with cleaner semi-real proportions.',
|
||||
notes: 'First believable player base. Use as camera / control reference for the Agora pass.',
|
||||
},
|
||||
{
|
||||
id: 'oracle-scholar',
|
||||
label: 'Oracle Scholar',
|
||||
zone: 'oracle',
|
||||
role: 'npc',
|
||||
modelPath: '/assets/hermesworld/characters/oracle-scholar.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Violet-blue robes, mystic trim, readable scholar silhouette.',
|
||||
notes: 'High-value NPC for questing, prophecy, and talk/gesture animation validation.',
|
||||
},
|
||||
{
|
||||
id: 'forge-blacksmith',
|
||||
label: 'Forge Blacksmith',
|
||||
zone: 'forge',
|
||||
role: 'npc',
|
||||
modelPath: '/assets/hermesworld/characters/forge-blacksmith.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Warm leather, metal, ember-orange accents.',
|
||||
notes: 'Use for prop interaction, forge-zone silhouette, and stronger grounded body type.',
|
||||
},
|
||||
{
|
||||
id: 'guard-knight',
|
||||
label: 'Guard Knight',
|
||||
zone: 'agora',
|
||||
role: 'npc',
|
||||
modelPath: '/assets/hermesworld/characters/guard-knight.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Structured armor silhouette with readable guard posture.',
|
||||
notes: 'Important for believable town square presence and stronger social framing.',
|
||||
},
|
||||
{
|
||||
id: 'merchant-villager',
|
||||
label: 'Merchant Villager',
|
||||
zone: 'agora',
|
||||
role: 'npc',
|
||||
modelPath: '/assets/hermesworld/characters/merchant-villager.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Civilian clothing, softer colors, market readability.',
|
||||
notes: 'Supports prop clusters and makes Agora feel inhabited rather than staged.',
|
||||
},
|
||||
{
|
||||
id: 'villager-common',
|
||||
label: 'Common Villager',
|
||||
zone: 'training-grounds',
|
||||
role: 'npc',
|
||||
modelPath: '/assets/hermesworld/characters/villager-common.glb',
|
||||
defaultScale: 1,
|
||||
paletteHint: 'Simple but believable peasant/traveler look.',
|
||||
notes: 'Cheap repeatable baseline for believable crowd fill before deeper variety.',
|
||||
},
|
||||
]
|
||||
|
||||
export const HERMESWORLD_CHARACTER_PIPELINE_NOTES = {
|
||||
sourcePriority: ['Ready Player Me', 'Mixamo', 'custom GLB cleanup'],
|
||||
immediateSprint: 'Agora believable',
|
||||
firstZoneGoal: 'Replace placeholder/toy-like figures with semi-real fantasy humans in Agora first.',
|
||||
performanceRules: [
|
||||
'Prefer GLB with compressed textures.',
|
||||
'Cap material count aggressively.',
|
||||
'Reuse rigs and animation clips across archetypes.',
|
||||
'Avoid shipping many unique characters before the first 4-6 feel real.',
|
||||
],
|
||||
} as const
|
||||
104
src/screens/playground/lib/playground-actions.ts
Normal file
104
src/screens/playground/lib/playground-actions.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { PlaygroundItemId, PlaygroundWorldId } from './playground-rpg'
|
||||
|
||||
/**
|
||||
* HermesWorld action contract.
|
||||
*
|
||||
* Agents should operate the world through deterministic verbs, not by
|
||||
* screen-clicking React controls. This file is the shared protocol surface
|
||||
* that can be wired into the human UI, background agents, offline progress,
|
||||
* and future arena/eval combat.
|
||||
*/
|
||||
export type PlaygroundActorKind = 'human' | 'agent' | 'npc'
|
||||
|
||||
export type PlaygroundActorRef = {
|
||||
id: string
|
||||
kind: PlaygroundActorKind
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type PlaygroundAction =
|
||||
| { kind: 'move_to'; targetId?: string; x?: number; z?: number }
|
||||
| { kind: 'talk_to'; npcId: string }
|
||||
| { kind: 'accept_quest'; questId: string }
|
||||
| { kind: 'complete_objective'; questId: string; objectiveId: string }
|
||||
| { kind: 'equip'; itemId: PlaygroundItemId }
|
||||
| { kind: 'travel'; worldId: PlaygroundWorldId }
|
||||
| { kind: 'attack'; targetId: string; abilityId?: string }
|
||||
| { kind: 'loot'; itemId?: PlaygroundItemId; targetId?: string }
|
||||
| { kind: 'rest' }
|
||||
|
||||
export type PlaygroundActionResult = {
|
||||
ok: boolean
|
||||
action: PlaygroundAction
|
||||
actor: PlaygroundActorRef
|
||||
message: string
|
||||
statePatch?: unknown
|
||||
emittedEvents?: PlaygroundWorldEvent[]
|
||||
suggestedNextActions?: PlaygroundAction[]
|
||||
errorCode?: 'invalid_action' | 'locked' | 'out_of_range' | 'missing_item' | 'cooldown' | 'not_found'
|
||||
}
|
||||
|
||||
export type PlaygroundWorldEvent = {
|
||||
id: string
|
||||
ts: number
|
||||
actorId: string
|
||||
actorKind: PlaygroundActorKind
|
||||
type:
|
||||
| 'move'
|
||||
| 'dialog'
|
||||
| 'quest_accept'
|
||||
| 'objective_complete'
|
||||
| 'equip'
|
||||
| 'travel'
|
||||
| 'combat'
|
||||
| 'loot'
|
||||
| 'rest'
|
||||
worldId?: PlaygroundWorldId
|
||||
targetId?: string
|
||||
summary: string
|
||||
public: boolean
|
||||
}
|
||||
|
||||
export type PlaygroundAgentWorldState = {
|
||||
actor: PlaygroundActorRef
|
||||
worldId: PlaygroundWorldId
|
||||
position: { x: number; z: number }
|
||||
hp?: number
|
||||
mp?: number
|
||||
sp?: number
|
||||
activeQuestId?: string
|
||||
activeObjectiveId?: string
|
||||
unlockedWorlds: PlaygroundWorldId[]
|
||||
inventory: PlaygroundItemId[]
|
||||
equipped: Partial<Record<'weapon' | 'cloak' | 'head' | 'artifact', PlaygroundItemId>>
|
||||
nearby: Array<{
|
||||
id: string
|
||||
kind: 'npc' | 'player' | 'item' | 'portal' | 'objective' | 'enemy'
|
||||
label: string
|
||||
distance: number
|
||||
verbs: PlaygroundAction['kind'][]
|
||||
}>
|
||||
}
|
||||
|
||||
export function describeAction(action: PlaygroundAction): string {
|
||||
switch (action.kind) {
|
||||
case 'move_to':
|
||||
return action.targetId ? `Move to ${action.targetId}` : `Move to ${action.x ?? 0}, ${action.z ?? 0}`
|
||||
case 'talk_to':
|
||||
return `Talk to ${action.npcId}`
|
||||
case 'accept_quest':
|
||||
return `Accept quest ${action.questId}`
|
||||
case 'complete_objective':
|
||||
return `Complete ${action.questId}/${action.objectiveId}`
|
||||
case 'equip':
|
||||
return `Equip ${action.itemId}`
|
||||
case 'travel':
|
||||
return `Travel to ${action.worldId}`
|
||||
case 'attack':
|
||||
return `Attack ${action.targetId}`
|
||||
case 'loot':
|
||||
return action.itemId ? `Loot ${action.itemId}` : `Loot ${action.targetId ?? 'target'}`
|
||||
case 'rest':
|
||||
return 'Rest'
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
src/server/claude-tasks-backend.test.ts
Normal file
122
src/server/claude-tasks-backend.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function loadBackend(options?: {
|
||||
cards?: Array<Record<string, unknown>>
|
||||
updatedCard?: Record<string, unknown> | null
|
||||
}) {
|
||||
const listKanbanCards = vi.fn(async () => options?.cards ?? [])
|
||||
const createKanbanCard = vi.fn(async (input) => ({
|
||||
id: 'card-created',
|
||||
title: input.title,
|
||||
spec: input.spec ?? '',
|
||||
acceptanceCriteria: [],
|
||||
assignedWorker: input.assignedWorker ?? null,
|
||||
reviewer: null,
|
||||
status: input.status ?? 'backlog',
|
||||
missionId: null,
|
||||
reportPath: null,
|
||||
createdBy: input.createdBy ?? 'user',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
}))
|
||||
const updateKanbanCard = vi.fn(async (_taskId, _updates) => options?.updatedCard ?? null)
|
||||
const getKanbanBackendMeta = vi.fn(() => ({
|
||||
id: 'hermes-proxy',
|
||||
label: 'Hermes Dashboard kanban',
|
||||
detected: true,
|
||||
writable: true,
|
||||
}))
|
||||
|
||||
vi.doMock('./kanban-backend', () => ({
|
||||
listKanbanCards,
|
||||
createKanbanCard,
|
||||
updateKanbanCard,
|
||||
getKanbanBackendMeta,
|
||||
}))
|
||||
|
||||
const mod = await import('./claude-tasks-backend')
|
||||
return { mod, listKanbanCards, createKanbanCard, updateKanbanCard, getKanbanBackendMeta }
|
||||
}
|
||||
|
||||
describe('claude-tasks-backend', () => {
|
||||
it('maps shared kanban cards into /tasks records and preserves blocked cards', async () => {
|
||||
const { mod } = await loadBackend({
|
||||
cards: [
|
||||
{
|
||||
id: 'card-1',
|
||||
title: 'Blocked card',
|
||||
spec: 'Investigate runtime edge case',
|
||||
acceptanceCriteria: [],
|
||||
assignedWorker: 'swarm6',
|
||||
reviewer: null,
|
||||
status: 'blocked',
|
||||
missionId: null,
|
||||
reportPath: null,
|
||||
createdBy: 'aurora',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_050_000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const tasks = await mod.listClaudeTasks({ includeDone: true })
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({
|
||||
id: 'card-1',
|
||||
title: 'Blocked card',
|
||||
description: 'Investigate runtime edge case',
|
||||
column: 'blocked',
|
||||
assignee: 'swarm6',
|
||||
created_by: 'aurora',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates tasks in the shared kanban backend instead of tasks.json', async () => {
|
||||
const { mod, createKanbanCard } = await loadBackend()
|
||||
|
||||
const task = await mod.createClaudeTask({
|
||||
title: 'Wire workspace board to shared kanban',
|
||||
description: 'Proxy through Agent API',
|
||||
column: 'todo',
|
||||
assignee: 'swarm3',
|
||||
created_by: 'user',
|
||||
})
|
||||
|
||||
expect(createKanbanCard).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'Wire workspace board to shared kanban',
|
||||
spec: 'Proxy through Agent API',
|
||||
assignedWorker: 'swarm3',
|
||||
status: 'ready',
|
||||
createdBy: 'user',
|
||||
}))
|
||||
expect(task).toMatchObject({ id: 'card-created', column: 'todo', assignee: 'swarm3' })
|
||||
})
|
||||
|
||||
it('moves running and blocked cards through kanban status updates', async () => {
|
||||
const { mod, updateKanbanCard } = await loadBackend({
|
||||
updatedCard: {
|
||||
id: 'card-2',
|
||||
title: 'Updated card',
|
||||
spec: 'Now blocked',
|
||||
acceptanceCriteria: [],
|
||||
assignedWorker: 'swarm5',
|
||||
reviewer: null,
|
||||
status: 'blocked',
|
||||
missionId: null,
|
||||
reportPath: null,
|
||||
createdBy: 'aurora',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_090_000,
|
||||
},
|
||||
})
|
||||
|
||||
const task = await mod.moveClaudeTask('card-2', 'blocked')
|
||||
expect(updateKanbanCard).toHaveBeenCalledWith('card-2', expect.objectContaining({ status: 'blocked' }))
|
||||
expect(task).toMatchObject({ id: 'card-2', column: 'blocked' })
|
||||
})
|
||||
})
|
||||
166
src/server/claude-tasks-backend.ts
Normal file
166
src/server/claude-tasks-backend.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
createKanbanCard,
|
||||
listKanbanCards,
|
||||
type KanbanBackendMeta,
|
||||
getKanbanBackendMeta,
|
||||
updateKanbanCard,
|
||||
} from './kanban-backend'
|
||||
|
||||
export type TaskColumn = 'backlog' | 'todo' | 'in_progress' | 'review' | 'blocked' | 'done'
|
||||
export type TaskPriority = 'high' | 'medium' | 'low'
|
||||
|
||||
export type ClaudeTaskRecord = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
column: TaskColumn
|
||||
priority: TaskPriority
|
||||
assignee: string | null
|
||||
tags: string[]
|
||||
due_date: string | null
|
||||
position: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type TaskFilters = {
|
||||
column?: string | null
|
||||
assignee?: string | null
|
||||
priority?: string | null
|
||||
includeDone?: boolean
|
||||
}
|
||||
|
||||
type CreateTaskInput = {
|
||||
title: string
|
||||
description?: string
|
||||
column?: TaskColumn
|
||||
priority?: TaskPriority
|
||||
assignee?: string | null
|
||||
tags?: string[]
|
||||
due_date?: string | null
|
||||
created_by?: string
|
||||
}
|
||||
|
||||
type UpdateTaskInput = Partial<Omit<CreateTaskInput, 'created_by'>>
|
||||
|
||||
function toIso(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
function mapKanbanStatusToTaskColumn(status: string): TaskColumn {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'todo'
|
||||
case 'running':
|
||||
return 'in_progress'
|
||||
case 'review':
|
||||
return 'review'
|
||||
case 'blocked':
|
||||
return 'blocked'
|
||||
case 'done':
|
||||
return 'done'
|
||||
case 'backlog':
|
||||
default:
|
||||
return 'backlog'
|
||||
}
|
||||
}
|
||||
|
||||
function mapTaskColumnToKanbanStatus(column: TaskColumn): 'backlog' | 'ready' | 'running' | 'review' | 'blocked' | 'done' {
|
||||
switch (column) {
|
||||
case 'todo':
|
||||
return 'ready'
|
||||
case 'in_progress':
|
||||
return 'running'
|
||||
case 'review':
|
||||
return 'review'
|
||||
case 'blocked':
|
||||
return 'blocked'
|
||||
case 'done':
|
||||
return 'done'
|
||||
case 'backlog':
|
||||
default:
|
||||
return 'backlog'
|
||||
}
|
||||
}
|
||||
|
||||
function mapCardToTask(card: {
|
||||
id: string
|
||||
title: string
|
||||
spec: string
|
||||
assignedWorker: string | null
|
||||
status: string
|
||||
createdBy: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}): ClaudeTaskRecord {
|
||||
return {
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
description: card.spec,
|
||||
column: mapKanbanStatusToTaskColumn(card.status),
|
||||
priority: 'medium',
|
||||
assignee: card.assignedWorker,
|
||||
tags: [],
|
||||
due_date: null,
|
||||
position: card.updatedAt,
|
||||
created_by: card.createdBy,
|
||||
created_at: toIso(card.createdAt),
|
||||
updated_at: toIso(card.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeTasksBackendMeta(): KanbanBackendMeta {
|
||||
return getKanbanBackendMeta()
|
||||
}
|
||||
|
||||
export async function listClaudeTasks(filters: TaskFilters = {}): Promise<ClaudeTaskRecord[]> {
|
||||
let tasks = (await listKanbanCards()).map(mapCardToTask)
|
||||
if (!filters.includeDone) {
|
||||
tasks = tasks.filter((task) => task.column !== 'done')
|
||||
}
|
||||
if (filters.column) {
|
||||
tasks = tasks.filter((task) => task.column === filters.column)
|
||||
}
|
||||
if (filters.assignee) {
|
||||
tasks = tasks.filter((task) => task.assignee === filters.assignee)
|
||||
}
|
||||
if (filters.priority) {
|
||||
tasks = tasks.filter((task) => task.priority === filters.priority)
|
||||
}
|
||||
return tasks.sort((a, b) => b.position - a.position || a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
export async function getClaudeTask(taskId: string): Promise<ClaudeTaskRecord | null> {
|
||||
const tasks = await listKanbanCards()
|
||||
const card = tasks.find((entry) => entry.id === taskId)
|
||||
return card ? mapCardToTask(card) : null
|
||||
}
|
||||
|
||||
export async function createClaudeTask(input: CreateTaskInput): Promise<ClaudeTaskRecord> {
|
||||
const card = await createKanbanCard({
|
||||
title: input.title,
|
||||
spec: input.description ?? '',
|
||||
assignedWorker: input.assignee ?? null,
|
||||
status: mapTaskColumnToKanbanStatus(input.column ?? 'backlog'),
|
||||
createdBy: input.created_by ?? 'user',
|
||||
})
|
||||
return mapCardToTask(card)
|
||||
}
|
||||
|
||||
export async function updateClaudeTask(taskId: string, updates: UpdateTaskInput): Promise<ClaudeTaskRecord | null> {
|
||||
const card = await updateKanbanCard(taskId, {
|
||||
title: typeof updates.title === 'string' ? updates.title : undefined,
|
||||
spec: typeof updates.description === 'string' ? updates.description : undefined,
|
||||
assignedWorker:
|
||||
updates.assignee === null || typeof updates.assignee === 'string'
|
||||
? updates.assignee
|
||||
: undefined,
|
||||
status: updates.column ? mapTaskColumnToKanbanStatus(updates.column) : undefined,
|
||||
})
|
||||
return card ? mapCardToTask(card) : null
|
||||
}
|
||||
|
||||
export async function moveClaudeTask(taskId: string, column: TaskColumn): Promise<ClaudeTaskRecord | null> {
|
||||
return updateClaudeTask(taskId, { column })
|
||||
}
|
||||
Reference in New Issue
Block a user