From 5f0fc86cf9373b3f67de976871def60597b62815 Mon Sep 17 00:00:00 2001 From: Richard Clark Date: Tue, 22 Sep 2026 13:12:54 -0400 Subject: [PATCH] Import verified AzerothAI snapshot from GitHub Source branch: feature/ai-brain-router Source commit: c2a09e0460465201a58aaab56c71424e9a96405e --- .env.example | 18 + .github/workflows/core-tests.yml | 23 + .github/workflows/full-verification.yml | 84 + .github/workflows/realm-simulation.yml | 91 + .github/workflows/wow-integration.yml | 192 + .gitignore | 51 + .npmrc | 3 + .openai/hosting.json | 5 + README.md | 98 + ale/azerothai_bridge.lua | 345 + app/api/actions/route.ts | 34 + app/api/ale/operator/route.ts | 30 + app/api/ale/status/route.ts | 17 + app/api/autonomy/tick/route.ts | 20 + app/api/bridge/ale/event/route.ts | 59 + app/api/bridge/ale/poll/route.ts | 21 + app/api/bridge/ale/result/route.ts | 21 + app/api/conversation/tick/route.ts | 20 + app/api/events/route.ts | 6 + app/api/health/route.ts | 28 + app/api/state/route.ts | 2 + app/api/world-monitor/route.ts | 70 + app/chatgpt-auth.ts | 90 + app/globals.css | 8 + app/layout.tsx | 26 + app/page.tsx | 143 + benchmarks/brain-scenarios.json | 52 + build/sites-vite-plugin.LICENSE | 21 + build/sites-vite-plugin.ts | 230 + cloudflare-env.d.ts | 6 + components.json | 21 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 196 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/attachment.tsx | 204 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 48 + components/ui/breadcrumb.tsx | 109 + components/ui/bubble.tsx | 125 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 64 + components/ui/calendar.tsx | 220 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 + components/ui/chart.tsx | 377 + components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/combobox.tsx | 310 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 + components/ui/dialog.tsx | 158 + components/ui/direction.tsx | 22 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 + components/ui/empty.tsx | 104 + components/ui/field.tsx | 248 + components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 170 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/marker.tsx | 69 + components/ui/menubar.tsx | 276 + components/ui/message-scroller.tsx | 130 + components/ui/message.tsx | 92 + components/ui/native-select.tsx | 62 + components/ui/navigation-menu.tsx | 168 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 89 + components/ui/progress.tsx | 32 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 53 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 190 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 143 + components/ui/sidebar.tsx | 723 ++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 40 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 35 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 91 + components/ui/textarea.tsx | 18 + components/ui/toggle-group.tsx | 83 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 57 + core/ale-auth.ts | 20 + core/ale-store.ts | 95 + core/ale.ts | 78 + core/autonomy.ts | 179 + core/connectors/index.ts | 49 + core/conversation-store.ts | 126 + core/conversation.ts | 187 + core/gm.ts | 19 + core/llm/index.ts | 110 + core/seed.ts | 79 + core/service.ts | 303 + core/store.ts | 80 + core/types.ts | 27 + core/validation.ts | 22 + core/world.ts | 84 + db/index.ts | 13 + db/schema.ts | 2 + deploy/pc-lab/README.md | 160 + deploy/pc-lab/check-lab.ps1 | 48 + deploy/pc-lab/export-for-mini-pc.sh | 72 + deploy/pc-lab/host-setup.ps1 | 174 + deploy/pc-lab/remote-dashboard-dev.sh | 158 + deploy/pc-lab/repair-remote-dashboard.sh | 41 + deploy/pc-lab/tailscale-dashboard.sh | 172 + deploy/pc-lab/vm-bootstrap.sh | 157 + deploy/playable/README.md | 234 + deploy/playable/bootstrap.sh | 333 + deploy/playable/enable-autonomous-world.sh | 410 + deploy/playable/install-ale-relay-service.sh | 93 + deploy/playable/install-autonomy-service.sh | 85 + deploy/playable/preflight.sh | 66 + deploy/playable/set-gm-credentials.sh | 38 + deploy/playable/status.sh | 145 + deploy/playable/stop.sh | 52 + docs/AI_BRAINS.md | 179 + docs/ALE_CONTROL_PLANE.md | 227 + docs/CONNECTORS.md | 59 + docs/LIVING_WORLD.md | 158 + docs/OPERATIONS.md | 39 + docs/ZAKU_HARDCORE.md | 84 + drizzle.config.ts | 7 + drizzle/0000_old_deathbird.sql | 5 + drizzle/meta/0000_snapshot.json | 49 + drizzle/meta/_journal.json | 13 + eslint.config.mjs | 28 + examples/d1/app/api/notes/route.ts | 58 + examples/d1/db/schema.ts | 9 + hooks/use-mobile.ts | 19 + lib/utils.ts | 6 + next.config.ts | 7 + package.json | 71 + pnpm-lock.yaml | 9201 ++++++++++++++++++ pnpm-workspace.yaml | 21 + postcss.config.mjs | 7 + public/favicon.svg | 1 + public/file.svg | 1 + public/globe.svg | 1 + public/window.svg | 1 + scripts/ale-capture-server.mjs | 89 + scripts/ale-db-relay.mjs | 165 + scripts/autonomy-worker.mjs | 52 + scripts/benchmark-brains.mjs | 35 + scripts/build-verified.sh | 26 + scripts/execution-profile.mjs | 16 + scripts/install-ci.mjs | 51 + scripts/install-ci.sh | 211 + scripts/install-pnpm.sh | 216 + scripts/pnpm-install.mjs | 261 + scripts/run-framework.mjs | 23 + scripts/simulate-live-realm.mjs | 192 + scripts/sites-env.mjs | 31 + scripts/sites-env.sh | 52 + scripts/smoke-brain.mjs | 45 + scripts/test-core.mjs | 6 + tests/ale.test.cjs | 36 + tests/brains.test.cjs | 42 + tests/core.test.cjs | 16 + tests/hardcore.test.cjs | 68 + tests/living-world.test.cjs | 78 + tsconfig.json | 47 + vendor/shadcn-tailwind-4.13.0.LICENSE.md | 21 + vendor/shadcn-tailwind-4.13.0.css | 629 ++ vite.config.ts | 68 + 175 files changed, 25807 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/core-tests.yml create mode 100644 .github/workflows/full-verification.yml create mode 100644 .github/workflows/realm-simulation.yml create mode 100644 .github/workflows/wow-integration.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .openai/hosting.json create mode 100644 README.md create mode 100644 ale/azerothai_bridge.lua create mode 100644 app/api/actions/route.ts create mode 100644 app/api/ale/operator/route.ts create mode 100644 app/api/ale/status/route.ts create mode 100644 app/api/autonomy/tick/route.ts create mode 100644 app/api/bridge/ale/event/route.ts create mode 100644 app/api/bridge/ale/poll/route.ts create mode 100644 app/api/bridge/ale/result/route.ts create mode 100644 app/api/conversation/tick/route.ts create mode 100644 app/api/events/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/state/route.ts create mode 100644 app/api/world-monitor/route.ts create mode 100644 app/chatgpt-auth.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 benchmarks/brain-scenarios.json create mode 100644 build/sites-vite-plugin.LICENSE create mode 100644 build/sites-vite-plugin.ts create mode 100644 cloudflare-env.d.ts create mode 100644 components.json create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/attachment.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/bubble.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/direction.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/marker.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/message-scroller.tsx create mode 100644 components/ui/message.tsx create mode 100644 components/ui/native-select.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 core/ale-auth.ts create mode 100644 core/ale-store.ts create mode 100644 core/ale.ts create mode 100644 core/autonomy.ts create mode 100644 core/connectors/index.ts create mode 100644 core/conversation-store.ts create mode 100644 core/conversation.ts create mode 100644 core/gm.ts create mode 100644 core/llm/index.ts create mode 100644 core/seed.ts create mode 100644 core/service.ts create mode 100644 core/store.ts create mode 100644 core/types.ts create mode 100644 core/validation.ts create mode 100644 core/world.ts create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 deploy/pc-lab/README.md create mode 100644 deploy/pc-lab/check-lab.ps1 create mode 100644 deploy/pc-lab/export-for-mini-pc.sh create mode 100644 deploy/pc-lab/host-setup.ps1 create mode 100644 deploy/pc-lab/remote-dashboard-dev.sh create mode 100644 deploy/pc-lab/repair-remote-dashboard.sh create mode 100644 deploy/pc-lab/tailscale-dashboard.sh create mode 100644 deploy/pc-lab/vm-bootstrap.sh create mode 100644 deploy/playable/README.md create mode 100644 deploy/playable/bootstrap.sh create mode 100644 deploy/playable/enable-autonomous-world.sh create mode 100644 deploy/playable/install-ale-relay-service.sh create mode 100644 deploy/playable/install-autonomy-service.sh create mode 100644 deploy/playable/preflight.sh create mode 100644 deploy/playable/set-gm-credentials.sh create mode 100644 deploy/playable/status.sh create mode 100644 deploy/playable/stop.sh create mode 100644 docs/AI_BRAINS.md create mode 100644 docs/ALE_CONTROL_PLANE.md create mode 100644 docs/CONNECTORS.md create mode 100644 docs/LIVING_WORLD.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/ZAKU_HARDCORE.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_old_deathbird.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 eslint.config.mjs create mode 100644 examples/d1/app/api/notes/route.ts create mode 100644 examples/d1/db/schema.ts create mode 100644 hooks/use-mobile.ts create mode 100644 lib/utils.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/window.svg create mode 100644 scripts/ale-capture-server.mjs create mode 100644 scripts/ale-db-relay.mjs create mode 100644 scripts/autonomy-worker.mjs create mode 100644 scripts/benchmark-brains.mjs create mode 100755 scripts/build-verified.sh create mode 100644 scripts/execution-profile.mjs create mode 100644 scripts/install-ci.mjs create mode 100755 scripts/install-ci.sh create mode 100755 scripts/install-pnpm.sh create mode 100644 scripts/pnpm-install.mjs create mode 100644 scripts/run-framework.mjs create mode 100644 scripts/simulate-live-realm.mjs create mode 100644 scripts/sites-env.mjs create mode 100755 scripts/sites-env.sh create mode 100644 scripts/smoke-brain.mjs create mode 100644 scripts/test-core.mjs create mode 100644 tests/ale.test.cjs create mode 100644 tests/brains.test.cjs create mode 100644 tests/core.test.cjs create mode 100644 tests/hardcore.test.cjs create mode 100644 tests/living-world.test.cjs create mode 100644 tsconfig.json create mode 100644 vendor/shadcn-tailwind-4.13.0.LICENSE.md create mode 100644 vendor/shadcn-tailwind-4.13.0.css create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d09441 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .dev.vars for local Worker preview. Never commit .dev.vars. +# Optional AzerothAI bridge; not a native AzerothCore REST endpoint. +BRIDGE_URL= +BRIDGE_TOKEN= +# Ollama root URL, or OpenAI-compatible root URL (without /v1). +LLM_URL= +LLM_API_KEY= +# Optional direct AzerothCore SOAP GM connection. +SOAP_URL= +SOAP_USER= +SOAP_PASSWORD= + +# ALE control plane. The bridge token is shared only between worldserver and AzerothAI. +ALE_BRIDGE_TOKEN= +# Operator token protects the full-control API, including raw Lua. +ALE_OPERATOR_TOKEN= +# Keep false until running on your private/local deployment. +ALE_RAW_EXEC_ENABLED=false diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml new file mode 100644 index 0000000..3a6c4c8 --- /dev/null +++ b/.github/workflows/core-tests.yml @@ -0,0 +1,23 @@ +name: Core tests + +on: + push: + branches: + - main + - feature/** + pull_request: + +jobs: + core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.25.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node scripts/test-core.mjs diff --git a/.github/workflows/full-verification.yml b/.github/workflows/full-verification.yml new file mode 100644 index 0000000..68df0e1 --- /dev/null +++ b/.github/workflows/full-verification.yml @@ -0,0 +1,84 @@ +name: Full verification + +on: + push: + branches: + - feature/ai-brain-router + workflow_dispatch: + +jobs: + application: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.25.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Core and simulation tests + run: node scripts/test-core.mjs + - name: Production build + run: pnpm build + - name: Validate deployment shell scripts + run: | + bash -n deploy/playable/*.sh + bash -n deploy/pc-lab/*.sh + - name: Validate Windows PowerShell helpers + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $files = Get-ChildItem deploy/pc-lab/*.ps1 + foreach ($file in $files) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_.Message } + throw "PowerShell parse failed: $($file.Name)" + } + } + - name: Validate ALE bridge Lua syntax + run: | + sudo apt-get update -qq + sudo apt-get install -y lua5.4 + luac5.4 -p ale/azerothai_bridge.lua + - name: Lint (advisory while prototype debt is cleaned up) + continue-on-error: true + run: pnpm lint + + local-brain: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.25.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Install Ollama + run: curl -fsSL https://ollama.com/install.sh | sh + - name: Start Ollama + run: | + nohup ollama serve >/tmp/ollama.log 2>&1 & + for i in {1..30}; do + if curl -fsS http://127.0.0.1:11434/api/tags >/dev/null; then exit 0; fi + sleep 2 + done + cat /tmp/ollama.log + exit 1 + - name: Pull Qwen3.5 4B + run: ollama pull qwen3.5:4b + - name: Run one real AzerothAI brain decision + env: + OLLAMA_URL: http://127.0.0.1:11434 + MODEL: qwen3.5:4b + run: node scripts/smoke-brain.mjs diff --git a/.github/workflows/realm-simulation.yml b/.github/workflows/realm-simulation.yml new file mode 100644 index 0000000..fa1e6ae --- /dev/null +++ b/.github/workflows/realm-simulation.yml @@ -0,0 +1,91 @@ +name: Realm simulation + +on: + push: + branches: + - feature/ai-brain-router + paths: + - "app/api/bridge/ale/**" + - "app/api/world-monitor/**" + - "core/service.ts" + - "core/autonomy.ts" + - "core/types.ts" + - "ale/azerothai_bridge.lua" + - "scripts/simulate-live-realm.mjs" + - "scripts/sites-env.mjs" + - "package.json" + - ".github/workflows/realm-simulation.yml" + workflow_dispatch: + +jobs: + simulated-live-world: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 11.25.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build AzerothAI + run: pnpm build + + - name: Configure simulated ALE worldserver + run: | + cat > .dev.vars <<'EOF' + ALE_BRIDGE_TOKEN=simulation-bridge-token + ALE_OPERATOR_TOKEN=simulation-operator-token + ALE_RAW_EXEC_ENABLED=false + AUTONOMY_TOKEN=simulation-autonomy-token + EOF + + - name: Initialize local realm database + run: | + node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js d1 execute DB --local --config dist/server/wrangler.json --persist-to .wrangler/state --file drizzle/0000_old_deathbird.sql + + - name: Start AzerothAI test runtime + run: | + nohup pnpm start >/tmp/azerothai-simulation.log 2>&1 & + echo $! >/tmp/azerothai-simulation.pid + for i in {1..60}; do + if curl -fsS http://127.0.0.1:8787/api/health >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/azerothai-simulation.log + exit 1 + + - name: Simulate AzerothCore + Playerbots + ALE world activity + env: + AZEROTHAI_URL: http://127.0.0.1:8787 + ALE_BRIDGE_TOKEN: simulation-bridge-token + AUTONOMY_TOKEN: simulation-autonomy-token + run: | + set -o pipefail + node scripts/simulate-live-realm.mjs | tee simulation-report.txt + + - name: Capture final monitor snapshots + if: always() + run: | + mkdir -p simulation-artifacts + cp simulation-report.txt simulation-artifacts/ 2>/dev/null || true + cp /tmp/azerothai-simulation.log simulation-artifacts/runtime.log 2>/dev/null || true + curl -fsS http://127.0.0.1:8787/api/state > simulation-artifacts/final-state.json 2>/dev/null || true + curl -fsS http://127.0.0.1:8787/api/world-monitor > simulation-artifacts/world-monitor.json 2>/dev/null || true + + - name: Upload simulation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: realm-simulation-report + path: simulation-artifacts/ + if-no-files-found: ignore diff --git a/.github/workflows/wow-integration.yml b/.github/workflows/wow-integration.yml new file mode 100644 index 0000000..0af9cc7 --- /dev/null +++ b/.github/workflows/wow-integration.yml @@ -0,0 +1,192 @@ +name: WoW integration lab + +on: + push: + branches: + - feature/ai-brain-router + paths: + - "ale/**" + - "core/**" + - "scripts/ale-capture-server.mjs" + - ".github/workflows/wow-integration.yml" + workflow_dispatch: + +jobs: + real-worldserver: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + DOCKER_VOL_DATA: /tmp/ac-client-data + DOCKER_VOL_ETC: /tmp/ac-etc + DOCKER_VOL_LOGS: /tmp/ac-logs + DOCKER_DB_ROOT_PASSWORD: ci-password + ALE_BRIDGE_TOKEN: ci-bridge-token + steps: + - name: Checkout AzerothAI + uses: actions/checkout@v4 + + - name: Start disposable AzerothAI bridge receiver + run: | + nohup node scripts/ale-capture-server.mjs >/tmp/ale-capture-server.log 2>&1 & + for i in {1..30}; do + if curl -fsS http://127.0.0.1:3000/health >/dev/null; then exit 0; fi + sleep 1 + done + cat /tmp/ale-capture-server.log + exit 1 + + - name: Clone Playerbots AzerothCore test server + run: | + git clone --depth 1 --branch Playerbot https://github.com/mod-playerbots/azerothcore-wotlk.git "/tmp/acore" + git clone --depth 1 --branch master https://github.com/mod-playerbots/mod-playerbots.git "/tmp/acore/modules/mod-playerbots" + git clone --depth 1 https://github.com/azerothcore/mod-ale.git "/tmp/acore/modules/mod-ale" + + - name: Install AzerothAI ALE bridge into test worldserver + run: | + mkdir -p "/tmp/acore/ci-lua" + cp ale/azerothai_bridge.lua "/tmp/acore/ci-lua/azerothai_bridge.lua" + python3 - <<'PY' + from pathlib import Path + p=Path("/tmp/acore/ci-lua/azerothai_bridge.lua") + s=p.read_text() + s=s.replace('local ROOT = GetConfigValue("AzerothAI.Url")','local ROOT = "http://host.docker.internal:3000"') + s=s.replace('local TOKEN = GetConfigValue("AzerothAI.Token")','local TOKEN = "ci-bridge-token"') + s=s.replace('local POLL_MS = tonumber(GetConfigValue("AzerothAI.PollMs")) or 750','local POLL_MS = 250') + s=s.replace('local PROTECTED = GetConfigValue("AzerothAI.ProtectedCharacter")','local PROTECTED = "Zaku"') + p.write_text(s) + PY + cat > "/tmp/acore/docker-compose.override.yml" <<'YAML' + services: + ac-worldserver: + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + AC_ALE_ENABLED: "1" + AC_ALE_TRACEBACK: "1" + AC_ALE_SCRIPTPATH: "lua_scripts" + AC_AI_PLAYERBOT_RANDOM_BOT_AUTOLOGIN: "0" + AC_AI_PLAYERBOT_MIN_RANDOM_BOTS: "0" + AC_AI_PLAYERBOT_MAX_RANDOM_BOTS: "0" + volumes: + - ./modules:/azerothcore/modules:ro + - ./ci-lua:/azerothcore/env/dist/bin/lua_scripts:ro + YAML + + - name: Prepare writable test volumes + run: | + mkdir -p "${DOCKER_VOL_DATA}" "${DOCKER_VOL_ETC}" "${DOCKER_VOL_LOGS}" + chmod 777 "${DOCKER_VOL_DATA}" "${DOCKER_VOL_ETC}" "${DOCKER_VOL_LOGS}" + + - name: Build real AzerothCore + Playerbots + ALE + working-directory: /tmp/acore + run: | + docker compose build ac-client-data-init ac-db-import ac-authserver ac-worldserver + + - name: Start MySQL + working-directory: /tmp/acore + run: | + docker compose up -d ac-database + for i in {1..60}; do + if docker inspect --format='{{json .State.Health.Status}}' ac-database 2>/dev/null | grep -q healthy; then exit 0; fi + sleep 2 + done + docker compose logs ac-database + exit 1 + + - name: Load AzerothCore client data + working-directory: /tmp/acore + run: | + set +e + docker compose up --no-deps ac-client-data-init + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + echo "Official client-data initializer failed; applying CI fallback." + rm -rf "${DOCKER_VOL_DATA:?}"/* + asset=$(curl -fsSL https://api.github.com/repos/wowgaming/client-data/releases/latest | jq -r '.assets[] | select(.name=="Data.zip" or .name=="data.zip") | .browser_download_url' | head -1) + test -n "$asset" + curl -fL --retry 3 "$asset" -o /tmp/ac-client-data.zip + unzip -q /tmp/ac-client-data.zip -d "${DOCKER_VOL_DATA}" + fi + test -d "${DOCKER_VOL_DATA}/dbc" + test -d "${DOCKER_VOL_DATA}/maps" + + - name: Import AzerothCore databases + working-directory: /tmp/acore + run: | + docker compose up --no-deps --abort-on-container-exit --exit-code-from ac-db-import ac-db-import + + - name: Boot authserver and worldserver + working-directory: /tmp/acore + run: | + docker compose up -d --no-deps ac-authserver + docker compose up -d --no-deps ac-worldserver + for i in {1..180}; do + if grep -q '"id":"ci-probe"' /tmp/ale-capture.jsonl 2>/dev/null; then + exit 0 + fi + if ! docker inspect ac-worldserver >/dev/null 2>&1; then + echo "worldserver container disappeared" + docker compose ps -a + docker compose logs ac-worldserver + exit 1 + fi + state=$(docker inspect --format='{{.State.Status}}' ac-worldserver 2>/dev/null || true) + if [ "$state" = "exited" ] || [ "$state" = "dead" ]; then + docker compose logs ac-worldserver + exit 1 + fi + sleep 2 + done + echo "Timed out waiting for ALE command round-trip" + docker compose logs ac-worldserver + cat /tmp/ale-capture-server.log || true + cat /tmp/ale-capture.jsonl || true + exit 1 + + - name: Verify ALE executes inside the real worldserver + run: | + cat /tmp/ale-capture.jsonl + node - <<'NODE' + const fs=require('fs'); + const rows=fs.readFileSync('/tmp/ale-capture.jsonl','utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + const ready=rows.find(r=>r.kind==='event'&&r.payload?.kind==='bridge.ready'); + if(!ready) throw new Error('ALE bridge.ready event never reached the receiver'); + const result=rows.find(r=>r.kind==='result'&&r.payload?.id==='ci-probe'); + if(!result) throw new Error('ALE operator probe never returned'); + if(result.payload.ok!==true) throw new Error('ALE operator probe failed: '+JSON.stringify(result.payload)); + const text=String(result.payload.result||''); + if(!text.includes('core=')) throw new Error('Core identity missing from ALE probe: '+text); + if(!text.toLowerCase().includes('lua=')) throw new Error('Lua engine identity missing from ALE probe: '+text); + console.log('REAL WORLDSERVER PROBE:',text); + NODE + + - name: Verify both modules are installed in the runtime + working-directory: /tmp/acore + run: | + docker exec ac-worldserver sh -lc 'find /azerothcore/env/dist/etc -maxdepth 3 -type f \( -iname "*ale*" -o -iname "*playerbot*" \) -print' | tee /tmp/module-configs.txt + grep -qi ale /tmp/module-configs.txt + grep -qi playerbot /tmp/module-configs.txt + docker compose logs ac-worldserver | grep -Ei 'ALE|playerbot|AzerothAI' | tail -200 || true + + - name: Save integration diagnostics + if: always() + run: | + mkdir -p integration-artifacts + cp /tmp/ale-capture.jsonl integration-artifacts/ 2>/dev/null || true + cp /tmp/ale-capture-server.log integration-artifacts/ 2>/dev/null || true + if [ -d "/tmp/acore" ]; then + cd "/tmp/acore" + docker compose ps -a > "${GITHUB_WORKSPACE}/integration-artifacts/docker-ps.txt" 2>&1 || true + docker compose logs ac-worldserver > "${GITHUB_WORKSPACE}/integration-artifacts/worldserver.log" 2>&1 || true + docker compose logs ac-authserver > "${GITHUB_WORKSPACE}/integration-artifacts/authserver.log" 2>&1 || true + docker compose logs ac-db-import > "${GITHUB_WORKSPACE}/integration-artifacts/db-import.log" 2>&1 || true + fi + + - name: Upload integration diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: wow-integration-diagnostics + path: integration-artifacts/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4662bfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +next-env.d.ts +/dist/ +/.wrangler/ +# Checkout-local execution profile and tool state; never publish them. +/.sites-runtime/ +/.agents/ +/.codex/ +/outputs/ +/work/ +/.test-build/ +*.tsbuildinfo +.dev.vars +.dev.vars.* +!.env.example diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9f274d6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..e0c0179 --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "d1": "DB", + "r2": null, + "project_id": "appgprj_6ab004795bf4819190876513d02ac93c" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d26ac16 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# AzerothAI — Living Realm prototype + +A working private web control plane for an AI-populated, self-hosted realm. The real player is the center of the experience: the Realm Director sets the tone, bot identities provide continuity, and the GM console lets the operator stage the world. + +**Boundary:** AzerothCore handles movement, combat, inventory, progression, and game rules. AzerothAI owns identity, personality, high-level goals, memory, and relationships. No model gets GM access. + +## What runs today + +- Dark navy/gold dashboard inspired by the supplied Living Realm mockup. +- Persistent bot creation, spawn, pause, resume, despawn, and retirement. +- Versioned recipes with per-bot snapshots; explicit recipe reapplication. +- Operator memories, generated observation memories, and directed relationships. +- Realm Director updates goals around a named player and focus zone. +- Manual decision loop: observe → assemble context → decide → validate → connector acknowledgment → memory/event. +- Reconnectable SSE event feed with sequence replay and a 500-event retention window. +- Deterministic personality simulator, Ollama provider, and OpenAI-compatible local model provider. +- GM terminal with history and simulated or confirmed live SOAP commands. +- Durable D1/SQLite state with optimistic revision checks and bounded operation leases. +- Modular TypeScript interfaces for additional game connectors and inference providers. +- Live World Monitor for Zaku, population bands, zones, Hardcore deaths, ALE pulse, and system health. +- Disposable playable realm profile for AzerothCore + Playerbots + ALE. +- Temporary Windows-PC/Ubuntu-VM lab profile with Windows-hosted Ollama/Qwen and migration export. + +The default demo contains six sample identities and four recipes. **It is not connected to an actual game server or LLM.** Simulation acknowledgments do not mean a character was created in WoW. + +## Source layout + +```text +app/page.tsx Dashboard and all management views +app/globals.css Navy/gold visual theme and responsive layout +app/api/state/route.ts Persistent realm snapshot +app/api/actions/route.ts Validated commands +app/api/events/route.ts Reconnectable SSE batches +app/api/health/route.ts Configuration availability (no secrets) +core/types.ts Game-neutral contracts and domain types +core/service.ts Lifecycle, recipes, memory, director, audit +core/validation.ts Zod input and decision validation +core/connectors/index.ts Simulation and AzerothCore HTTP bridge adapters +core/llm/index.ts Deterministic, Ollama, compatible providers +core/gm.ts Human-only allowlisted SOAP command channel +core/store.ts D1 repository and optimistic concurrency +core/seed.ts Explicit demo data + db/schema.ts Drizzle schema + drizzle/ Generated schema migrations + docs/CONNECTORS.md Bridge contract and extension guide + docs/OPERATIONS.md Local setup, credentials, limitations + tests/core.test.cjs Core behavior and failure tests + scripts/test-core.mjs Compile/run tests + deploy/playable/ Private playable-realm bootstrap + deploy/pc-lab/ Temporary Windows + Ubuntu VM lab +``` + +## Local quick start + +Use Node 22.13+ and the pnpm version pinned in `package.json`. The project runs locally with Cloudflare's Worker emulator and SQLite; no paid cloud account or model is required for simulation. + +```bash +corepack enable +pnpm install --frozen-lockfile +pnpm build +node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js d1 execute DB --local --config dist/server/wrangler.json --persist-to .wrangler/state --file drizzle/0000_old_deathbird.sql +pnpm start +``` + +Open the loopback URL printed by Wrangler (normally http://127.0.0.1:8787). `pnpm start` is local, not a deployment. Preserve `.wrangler/state` for persistence. The managed Work environment uses its supervised preview instead. + +For live integrations, copy `.env.example` to `.dev.vars`, populate the needed settings, and restart the local runtime. Do not commit that file. Hosted settings are supplied separately through the hosting platform. A cloud-hosted app cannot reach your home server's `localhost`. + +## Fastest path to a playable temporary lab + +For a Windows gaming PC with an Ubuntu VM, use `deploy/pc-lab/README.md`. It keeps WoW and Qwen/Ollama on Windows while AzerothCore, Playerbots, ALE, MySQL, and AzerothAI run inside the VM. + +For a dedicated Linux host, use `deploy/playable/README.md`. + +## Try the intended flow + +1. Open Realm Director. Pick the experience, enter your character and zone, and apply your brief. +2. Create an offline bot with a recipe. Open its profile and spawn it in simulation. +3. Add a memory and a relationship. Run a decision and inspect the resulting intent. +4. Edit its recipe. Notice that the bot keeps its old snapshot until explicitly updated. +5. Open GM Console and try `.announce Welcome to the realm!`. The response is labeled simulated. +6. Watch Event Stream and reload the app to verify state persists. + +## Checks + +```bash +node node_modules/typescript/bin/tsc --noEmit +node scripts/test-core.mjs +pnpm build +``` + +## Integration status + +The GM SOAP client is implemented against AzerothCore's documented remote-access protocol; live execution needs your server and credentials. The bot HTTP connector is an implemented client for **our bridge contract**, not an assertion that stock AzerothCore exposes those endpoints. A game-side adapter for your actual core/Playerbots branch still needs implementation and end-to-end testing. + +AzerothCore version/client selection must be settled before that adapter is written; a Classic-style experience does not establish compatibility with the modern WoW Classic client. This prototype deliberately stores no client-version assumption in its domain model. + +See [operations](docs/OPERATIONS.md) for operational limits and [connectors](docs/CONNECTORS.md) for API contracts. diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua new file mode 100644 index 0000000..0fd512a --- /dev/null +++ b/ale/azerothai_bridge.lua @@ -0,0 +1,345 @@ +-- AzerothAI <-> AzerothCore ALE bridge +-- Install this file into AzerothCore/lua_scripts after building mod-ale. +-- +-- The live command/event transport is intentionally database-backed: +-- AzerothAI API <-> host relay <-> acore_auth spool <-> ALE +-- This avoids mod-ale's outbound HTTP worker entirely for the control plane. +-- +-- Optional worldserver.conf additions: +-- AzerothAI.PollMs = "750" +-- AzerothAI.ProtectedCharacter = "Zaku" + +local POLL_MS = tonumber(GetConfigValue("AzerothAI.PollMs")) or 750 +local PROTECTED = GetConfigValue("AzerothAI.ProtectedCharacter") +if not PROTECTED or PROTECTED == "" then PROTECTED = "Zaku" end + +local polling = false +-- The deployment helper fills this with authoritative RNDBOT account IDs. +-- This makes bot detection work even on an older mod-ale build that does not +-- yet expose IsPlayerbot()/IsRandomBot() to Lua. +local KNOWN_BOT_ACCOUNTS = {} + +local function is_playerbot(player) + if not player then return false end + local ok,value = pcall(function() return player:IsPlayerbot() end) + if ok and value then return true end + ok,value = pcall(function() return player:IsRandomBot() end) + if ok and value then return true end + ok,value = pcall(function() return player:IsBot() end) + if ok and value then return true end + local okAccount,accountId = pcall(function() return player:GetAccountId() end) + if okAccount and KNOWN_BOT_ACCOUNTS[tonumber(accountId) or -1] then return true end + return false +end + +local function escape_json(value) + value = tostring(value or "") + value = string.gsub(value, "\\", "\\\\") + value = string.gsub(value, '"', '\\"') + value = string.gsub(value, "\r", "\\r") + value = string.gsub(value, "\n", "\\n") + value = string.gsub(value, "\t", "\\t") + return value +end + +local function json_value(value) + local t = type(value) + if value == nil then return "null" end + if t == "boolean" then return value and "true" or "false" end + if t == "number" then return tostring(value) end + return '"' .. escape_json(value) .. '"' +end + +local function encode_object(tbl) + local parts = {} + for k,v in pairs(tbl or {}) do + table.insert(parts, '"' .. escape_json(k) .. '":' .. json_value(v)) + end + return "{" .. table.concat(parts, ",") .. "}" +end + +local function to_hex(value) + value=tostring(value or "") + local out={} + for i=1,#value do + out[#out+1]=string.format("%02X",string.byte(value,i)) + end + return table.concat(out) +end + +local function db_text(hex) + return "CONVERT(UNHEX('" .. hex .. "') USING utf8mb4)" +end + +function AzerothAIComplete(id, ok, result) + local sql="INSERT INTO azerothai_result_outbox(command_id,ok,result) VALUES (" .. + db_text(to_hex(id)) .. "," .. (ok and "1" or "0") .. "," .. db_text(to_hex(result)) .. + ") ON DUPLICATE KEY UPDATE ok=VALUES(ok),result=VALUES(result),delivered_at=NULL" + AuthDBExecute(sql) +end + +local function emit(kind, payload) + local body=encode_object(payload) + local sql="INSERT INTO azerothai_event_outbox(kind,payload) VALUES (" .. + db_text(to_hex(kind)) .. "," .. db_text(to_hex(body)) .. ")" + AuthDBExecute(sql) +end + +local function player_payload(player) + if not player then return {} end + local isBot = is_playerbot(player) + return { + name=player:GetName(), + characterGuid=player:GetGUIDLow(), + accountId=player:GetAccountId(), + level=player:GetLevel(), + mapId=player:GetMapId(), + zoneId=player:GetZoneId(), + areaId=player:GetAreaId(), + zoneName=GetAreaName(player:GetZoneId()) or tostring(player:GetZoneId()), + race=player:GetRace(), + class=player:GetClass(), + raceName=player:GetRaceAsString() or tostring(player:GetRace()), + className=player:GetClassAsString() or tostring(player:GetClass()), + healthPct=player:GetHealthPct(), + isBot=isBot + } +end + +local function selected_bot_name(player) + local ok,target=pcall(function() return player:GetSelection() end) + if not ok or not target or not is_playerbot(target) then return "" end + local okName,name=pcall(function() return target:GetName() end) + if okName and name then return tostring(name) end + return "" +end + +local function nearby_bot_names(player) + local ok,players=pcall(function() return player:GetPlayersInRange(35) end) + if not ok or type(players)~="table" then return "" end + local names={} + for _,other in pairs(players) do + if other and is_playerbot(other) then + local okName,name=pcall(function() return other:GetName() end) + if okName and name and name~="" then table.insert(names,tostring(name)) end + end + end + table.sort(names) + return table.concat(names,",") +end + +function AzerothAIEmitPlayerSnapshot(name) + if not name or name == "" then return 0 end + local ok,player=pcall(function() return GetPlayerByName(name) end) + if not ok or not player or not is_playerbot(player) then return 0 end + emit("player.snapshot",player_payload(player)) + return 1 +end + +local function mark_consumed(id) + AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id))) +end + +local function poll() + if polling then return end + polling=true + local ok,err=pcall(function() + local q=AuthDBQuery("SELECT id,source FROM azerothai_command_inbox WHERE consumed_at IS NULL ORDER BY created_at,id LIMIT 1") + if not q then return end + local id=q:GetString(0) + local source=q:GetString(1) + local loader=load or loadstring + local chunk,compileErr=loader(source) + if not chunk then + AzerothAIComplete(id,false,"command compile failed: "..tostring(compileErr)) + mark_consumed(id) + return + end + local ran,runErr=pcall(chunk) + if not ran then + AzerothAIComplete(id,false,"command crashed: "..tostring(runErr)) + end + mark_consumed(id) + end) + polling=false + if not ok then PrintError("[AzerothAI] DB command poll crashed: "..tostring(err)) end +end + +-- Durable command lane: the host relay places wrapped Lua commands into the +-- auth database, so ALE never depends on its fragile outbound HTTP client. +CreateLuaEvent(function() poll() end, POLL_MS, 0) + +-- Keep AzerothAI grounded in real online Playerbots even when no human is logged in. +-- Expose a safe global refresh hook too, so the autonomy worker can explicitly +-- request a fresh authoritative snapshot if the periodic timer ever stalls. +function AzerothAIEmitSnapshots() + local ok,players=pcall(function() return GetPlayersInWorld() end) + if not ok or type(players)~="table" then return 0 end + local emitted=0 + for _,player in pairs(players) do + if is_playerbot(player) then + emit("player.snapshot",player_payload(player)) + emitted=emitted+1 + end + end + return emitted +end + +CreateLuaEvent(function() AzerothAIEmitSnapshots() end, 30000, 0) + +-- High-value authoritative world events. +RegisterServerEvent(14, function(event) + emit("server.startup",{players=GetPlayerCount()}) +end) + +RegisterServerEvent(15, function(event) + emit("server.shutdown",{players=GetPlayerCount()}) +end) + +RegisterServerEvent(25, function(event, zoneId, state, grade) + emit("world.weather",{zoneId=zoneId,state=state,grade=grade}) +end) + +RegisterServerEvent(34, function(event, gameEventId) + emit("world.game-event.start",{gameEventId=gameEventId}) +end) + +RegisterServerEvent(35, function(event, gameEventId) + emit("world.game-event.stop",{gameEventId=gameEventId}) +end) + +RegisterPlayerEvent(3, function(event, player) + emit("player.login",player_payload(player)) +end) + +RegisterPlayerEvent(4, function(event, player) + emit("player.logout",player_payload(player)) +end) + +RegisterPlayerEvent(8, function(event, killer, killed) + local payload=player_payload(killed) + payload.killer = killer and killer:GetName() or "creature" + emit("player.killed-by-creature",payload) +end) + +RegisterPlayerEvent(13, function(event, player, oldLevel) + local payload=player_payload(player) + payload.oldLevel=oldLevel + emit("player.level",payload) +end) + +RegisterPlayerEvent(18, function(event, player, msg, chatType, lang) + local payload=player_payload(player) + payload.message=msg + payload.chatType=chatType + payload.language=lang + if not payload.isBot then + payload.selectedPlayer=selected_bot_name(player) + payload.nearbyBots=nearby_bot_names(player) + end + emit("player.chat",payload) +end) + +RegisterPlayerEvent(19, function(event, player, msg, chatType, lang, receiver) + local payload=player_payload(player) + payload.message=msg + payload.chatType=chatType + payload.language=lang + payload.receiver=receiver and receiver:GetName() or "" + payload.receiverIsBot=receiver and is_playerbot(receiver) or false + emit("player.whisper",payload) +end) + +RegisterPlayerEvent(27, function(event, player, newZone, newArea) + local payload=player_payload(player) + payload.zone=newZone + payload.area=newArea + payload.zoneName=GetAreaName(newZone) or tostring(newZone) + emit("player.zone",payload) +end) + +RegisterPlayerEvent(28, function(event, player) + emit("player.map",player_payload(player)) +end) + +RegisterPlayerEvent(30, function(event, player) + emit("player.first-login",player_payload(player)) +end) + +local COMBAT_EVENT_INTERVAL = 10 +local lastCombatEvent = {} + +local function emit_combat(kind, player, payload) + local guid=player and player:GetGUIDLow() or 0 + local key=tostring(guid) + local now=os.time() + local last=lastCombatEvent[key] or 0 + if now-last < COMBAT_EVENT_INTERVAL then return end + lastCombatEvent[key]=now + emit(kind,payload) +end + +RegisterPlayerEvent(33, function(event, player, enemy) + local payload=player_payload(player) + payload.enemy=enemy and enemy:GetName() or "" + emit_combat("player.combat.enter",player,payload) +end) + +RegisterPlayerEvent(34, function(event, player) + emit_combat("player.combat.leave",player,player_payload(player)) +end) + +RegisterPlayerEvent(35, function(event, player) + emit("player.repop",player_payload(player)) +end) + +RegisterPlayerEvent(36, function(event, player) + emit("player.resurrect",player_payload(player)) +end) + +RegisterPlayerEvent(45, function(event, player, achievement) + local payload=player_payload(player) + payload.achievement=tostring(achievement) + emit("player.achievement",payload) +end) + +RegisterPlayerEvent(63, function(event, player, quest) + local payload=player_payload(player) + payload.quest=tostring(quest) + emit("player.quest.accept",payload) +end) + +-- Zaku plot armor: clamp common player/creature-originated damage paths to leave 1 HP. +-- This is runtime-only. Never send this rule into Zaku's AI prompt or memories. +local function clamp_target_damage(target, damage) + if not target or not target.GetName or target:GetName() ~= PROTECTED then return damage end + local health=target:GetHealth() + if health <= 1 then return 0 end + if damage >= health then + local payload=player_payload(target) + payload.healthBefore=health + payload.incomingDamage=damage + emit("player.close-call",payload) + return health - 1 + end + return damage +end + +local function clamp_player_damage(event, player, target, damage, ...) + return clamp_target_damage(target, damage) +end + +for _,eventId in ipairs({66,68,69,70,72}) do + RegisterPlayerEvent(eventId, clamp_player_damage) +end + +local function clamp_creature_damage(event, creature, target, damage, ...) + return clamp_target_damage(target, damage) +end + +for _,eventId in ipairs({7,9,10,11,13}) do + RegisterAllCreatureEvent(eventId, clamp_creature_damage) +end + +emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED}) +PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms") diff --git a/app/api/actions/route.ts b/app/api/actions/route.ts new file mode 100644 index 0000000..aa1275c --- /dev/null +++ b/app/api/actions/route.ts @@ -0,0 +1,34 @@ +import { env } from 'cloudflare:workers'; +import { D1Store } from '@/core/store'; +import { execute } from '@/core/service'; +import { runLiveAutonomyTick } from '@/core/autonomy'; +import type { RuntimeConfig } from '@/core/types'; +function requestOriginAllowed(request:Request){ + const origin=request.headers.get('origin'); + if(!origin)return true; + if(origin===new URL(request.url).origin)return true; + const proto=(request.headers.get('x-forwarded-proto')??'').split(',')[0]?.trim().toLowerCase(); + const host=(request.headers.get('x-forwarded-host')??request.headers.get('host')??'').split(',')[0]?.trim(); + if((proto==='http'||proto==='https')&&host&&origin===`${proto}://${host}`)return true; + return false; +} +export async function POST(request:Request){ + if(!requestOriginAllowed(request))return Response.json({error:'Cross-origin action rejected'},{status:403}); + try{ + const text=await request.text(); + if(text.length>16000)return Response.json({error:'Request too large'},{status:413}); + const body=JSON.parse(text); + const store=new D1Store(); + if(body?.type==='think'&&typeof body.botId==='string'){ + const state=await store.load(); + const bot=state.bots.find(b=>b.id===body.botId); + if(bot?.autonomy?.managed){ + const result=await runLiveAutonomyTick(store,env.DB!,env as unknown as RuntimeConfig,{forceBotId:bot.id}); + if(!result.ran)throw new Error(result.reason); + return Response.json(result); + } + } + return Response.json(await execute(store,body,env as unknown as RuntimeConfig)); + } + catch(e){const message=e instanceof Error?e.message:'Action failed';return Response.json({error:message},{status:400});} +} diff --git a/app/api/ale/operator/route.ts b/app/api/ale/operator/route.ts new file mode 100644 index 0000000..a2a1301 --- /dev/null +++ b/app/api/ale/operator/route.ts @@ -0,0 +1,30 @@ +import { env } from 'cloudflare:workers'; +import { z } from 'zod'; +import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth'; +import { compileAleOperatorRequest } from '@/core/ale'; +import { queueAleCommand } from '@/core/ale-store'; +import type { RuntimeConfig } from '@/core/types'; + +const scalar=z.union([z.string(),z.number(),z.boolean(),z.null()]); +const schema=z.discriminatedUnion('kind',[ + z.object({kind:z.literal('command'),command:z.string().min(1).max(500)}), + z.object({kind:z.literal('global'),method:z.string().min(1).max(100),args:z.array(scalar).max(32).optional()}), + z.object({kind:z.literal('player'),player:z.string().min(1).max(24),method:z.string().min(1).max(100),args:z.array(scalar).max(32).optional()}), + z.object({kind:z.literal('playerbotAI'),player:z.string().min(1).max(24),method:z.string().min(1).max(100),args:z.array(scalar).max(32).optional()}), + z.object({kind:z.literal('raw'),source:z.string().min(1).max(12000)}) +]); + +export async function POST(request:Request){ + const config=env as unknown as RuntimeConfig; + try{ + requireAleOperator(request,config); + const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413}); + const body=schema.parse(JSON.parse(text)); + const source=compileAleOperatorRequest(body,rawAleEnabled(config)); + const queued=await queueAleCommand(env.DB!,source); + return Response.json({ok:true,command:queued},{status:202}); + }catch(e){ + const message=e instanceof Error?e.message:'ALE operator failure'; + return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:400}); + } +} diff --git a/app/api/ale/status/route.ts b/app/api/ale/status/route.ts new file mode 100644 index 0000000..0141d6b --- /dev/null +++ b/app/api/ale/status/route.ts @@ -0,0 +1,17 @@ +import { env } from 'cloudflare:workers'; +import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth'; +import { recentAleCommands,recentAleEvents } from '@/core/ale-store'; +import { ALE_SURFACE } from '@/core/ale'; +import type { RuntimeConfig } from '@/core/types'; + +export async function GET(request:Request){ + const config=env as unknown as RuntimeConfig; + try{ + requireAleOperator(request,config); + const [commands,events]=await Promise.all([recentAleCommands(env.DB!,50),recentAleEvents(env.DB!,100)]); + return Response.json({ok:true,rawEnabled:rawAleEnabled(config),surface:ALE_SURFACE,commands,events},{headers:{'Cache-Control':'no-store'}}); + }catch(e){ + const message=e instanceof Error?e.message:'ALE status failure'; + return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500}); + } +} diff --git a/app/api/autonomy/tick/route.ts b/app/api/autonomy/tick/route.ts new file mode 100644 index 0000000..57aae2f --- /dev/null +++ b/app/api/autonomy/tick/route.ts @@ -0,0 +1,20 @@ +import {env} from 'cloudflare:workers'; +import {runLiveAutonomyTick} from '@/core/autonomy'; +import {D1Store} from '@/core/store'; +import type {RuntimeConfig} from '@/core/types'; + +function authorized(request:Request,config:RuntimeConfig){ + const token=config.AUTONOMY_TOKEN; + if(!token)return false; + return request.headers.get('authorization')===`Bearer ${token}`; +} + +export async function POST(request:Request){ + const config=env as unknown as RuntimeConfig; + if(!authorized(request,config))return Response.json({error:'Unauthorized autonomy heartbeat'},{status:401}); + try{ + return Response.json(await runLiveAutonomyTick(new D1Store(),env.DB!,config)); + }catch(e){ + return Response.json({error:e instanceof Error?e.message:'Autonomy heartbeat failed'},{status:500}); + } +} diff --git a/app/api/bridge/ale/event/route.ts b/app/api/bridge/ale/event/route.ts new file mode 100644 index 0000000..758a74b --- /dev/null +++ b/app/api/bridge/ale/event/route.ts @@ -0,0 +1,59 @@ +import { env } from 'cloudflare:workers'; +import { z } from 'zod'; +import { requireAleBridge } from '@/core/ale-auth'; +import { recordAleEvent } from '@/core/ale-store'; +import { queueConversationEvent } from '@/core/conversation-store'; +import { D1Store } from '@/core/store'; +import { applyAuthoritativeAleEvent,execute } from '@/core/service'; +import type { RuntimeConfig } from '@/core/types'; + +const schema=z.object({ + kind:z.string().min(1).max(100), + payload:z.record(z.unknown()).default({}) +}).strict(); + +const STATEFUL_EVENTS=new Set([ + 'bridge.ready', + 'player.login', + 'player.logout', + 'player.snapshot', + 'player.first-login', + 'player.zone', + 'player.map', + 'player.level', + 'player.quest.accept', + 'player.achievement', + 'player.close-call', + 'player.killed-by-creature', + 'player.repop', + 'player.resurrect' +]); + +export async function POST(request:Request){ + const config=env as unknown as RuntimeConfig; + try{ + requireAleBridge(request,config); + const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413}); + const body=schema.parse(JSON.parse(text)); + await recordAleEvent(env.DB!,body.kind,body.payload); + + if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){ + await queueConversationEvent(env.DB!,body.kind,body.payload); + } + + const store=new D1Store(); + if(STATEFUL_EVENTS.has(body.kind)){ + await applyAuthoritativeAleEvent(store,body.kind,body.payload); + } + + if(body.kind==='player.zone'&&body.payload.isBot!==true){ + const zone=String(body.payload.zoneName??body.payload.zone??'').slice(0,120); + if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config); + } + + return Response.json({ok:true}); + }catch(e){ + const message=e instanceof Error?e.message:'ALE event failure'; + return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:400}); + } +} diff --git a/app/api/bridge/ale/poll/route.ts b/app/api/bridge/ale/poll/route.ts new file mode 100644 index 0000000..c8c1f6b --- /dev/null +++ b/app/api/bridge/ale/poll/route.ts @@ -0,0 +1,21 @@ +import { env } from 'cloudflare:workers'; +import { requireAleBridge } from '@/core/ale-auth'; +import { pollAleCommand } from '@/core/ale-store'; +import { wrapAleCommand } from '@/core/ale'; +import type { RuntimeConfig } from '@/core/types'; + +async function handlePoll(request:Request){ + const config=env as unknown as RuntimeConfig; + try{ + requireAleBridge(request,config); + const command=await pollAleCommand(env.DB!); + if(!command)return new Response('return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}}); + return new Response(wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}}); + }catch(e){ + const message=e instanceof Error?e.message:'ALE bridge failure'; + return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500}); + } +} + +export async function GET(request:Request){ return handlePoll(request); } +export async function POST(request:Request){ return handlePoll(request); } diff --git a/app/api/bridge/ale/result/route.ts b/app/api/bridge/ale/result/route.ts new file mode 100644 index 0000000..cc3e06b --- /dev/null +++ b/app/api/bridge/ale/result/route.ts @@ -0,0 +1,21 @@ +import { env } from 'cloudflare:workers'; +import { z } from 'zod'; +import { requireAleBridge } from '@/core/ale-auth'; +import { completeAleCommand } from '@/core/ale-store'; +import type { RuntimeConfig } from '@/core/types'; + +const schema=z.object({id:z.string().uuid(),ok:z.boolean(),result:z.string().max(16000)}).strict(); + +export async function POST(request:Request){ + const config=env as unknown as RuntimeConfig; + try{ + requireAleBridge(request,config); + const text=await request.text();if(text.length>20000)return Response.json({error:'Result too large'},{status:413}); + const body=schema.parse(JSON.parse(text)); + await completeAleCommand(env.DB!,body.id,body.ok,body.result); + return Response.json({ok:true}); + }catch(e){ + const message=e instanceof Error?e.message:'ALE result failure'; + return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:400}); + } +} diff --git a/app/api/conversation/tick/route.ts b/app/api/conversation/tick/route.ts new file mode 100644 index 0000000..ff20c90 --- /dev/null +++ b/app/api/conversation/tick/route.ts @@ -0,0 +1,20 @@ +import {env} from 'cloudflare:workers'; +import {runConversationTick} from '@/core/conversation'; +import {D1Store} from '@/core/store'; +import type {RuntimeConfig} from '@/core/types'; + +function authorized(request:Request,config:RuntimeConfig){ + const token=config.AUTONOMY_TOKEN; + if(!token)return false; + return request.headers.get('authorization')===`Bearer ${token}`; +} + +export async function POST(request:Request){ + const config=env as unknown as RuntimeConfig; + if(!authorized(request,config))return Response.json({error:'Unauthorized conversation heartbeat'},{status:401}); + try{ + return Response.json(await runConversationTick(new D1Store(),env.DB!,config)); + }catch(e){ + return Response.json({error:e instanceof Error?e.message:'Conversation heartbeat failed'},{status:500}); + } +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..bb0109c --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,6 @@ +import { D1Store } from '@/core/store'; +// Reconnectable SSE batches with Last-Event-ID replay. Closing each batch avoids +// tying a Worker invocation to an unbounded loop; EventSource reconnects in 3s. +export async function GET(request:Request){ + try{const s=await new D1Store().load();const raw=request.headers.get('last-event-id')??new URL(request.url).searchParams.get('after')??'0';const after=Math.max(0,Number(raw)||0);let data='retry: 3000\n\n';if(after&&s.events[0]?.seq>after+1)data+='event: gap\ndata: {"reload":true}\n\n';for(const e of s.events.filter(e=>e.seq>after))data+=`id: ${e.seq}\nevent: realm\ndata: ${JSON.stringify(e)}\n\n`;return new Response(data+': heartbeat\n\n',{headers:{'Content-Type':'text/event-stream','Cache-Control':'no-cache'}});}catch{return Response.json({error:'Event stream unavailable'},{status:503});} +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..abad6e9 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,28 @@ +import { env } from 'cloudflare:workers'; +import { D1Store } from '@/core/store'; +import { rawAleEnabled } from '@/core/ale-auth'; +import { recentAleEvents } from '@/core/ale-store'; +import type { RuntimeConfig } from '@/core/types'; + +export async function GET(){ + const c=env as unknown as RuntimeConfig; + try{ + await new D1Store().load(); + const aleEvents=await recentAleEvents(env.DB!,20); + const lastAleAt=aleEvents[0]?.at??null; + const aleActiveRecently=!!lastAleAt&&Date.now()-Date.parse(lastAleAt)<90_000; + return Response.json({ + ok:true, + aleActiveRecently, + lastAleAt, + bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN), + aleConfigured:!!c.ALE_BRIDGE_TOKEN, + aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN, + aleRawEnabled:rawAleEnabled(c), + llmConfigured:!!c.LLM_URL, + soapConfigured:!!(c.SOAP_URL&&c.SOAP_USER&&c.SOAP_PASSWORD) + }); + }catch{ + return Response.json({ok:false},{status:503}); + } +} diff --git a/app/api/state/route.ts b/app/api/state/route.ts new file mode 100644 index 0000000..d7324d6 --- /dev/null +++ b/app/api/state/route.ts @@ -0,0 +1,2 @@ +import { D1Store } from '@/core/store'; +export async function GET(){try{return Response.json(await new D1Store().load(),{headers:{'Cache-Control':'no-store'}});}catch{return Response.json({error:'Realm database unavailable. Please retry.'},{status:503});}} diff --git a/app/api/world-monitor/route.ts b/app/api/world-monitor/route.ts new file mode 100644 index 0000000..65ea710 --- /dev/null +++ b/app/api/world-monitor/route.ts @@ -0,0 +1,70 @@ +import { env } from 'cloudflare:workers'; +import { recentAleCommands,recentAleEvents } from '@/core/ale-store'; + +type SafeWorldEvent={ + id:number; + at:string; + kind:string; + name?:string; + zone?:string; + level?:number; + isBot?:boolean; +}; + +function safeEvent(event:{id:number;at:string;kind:string;payload:unknown}):SafeWorldEvent{ + const payload=(event.payload&&typeof event.payload==='object'?event.payload:{}) as Record; + const out:SafeWorldEvent={id:event.id,at:event.at,kind:event.kind}; + if(typeof payload.name==='string')out.name=payload.name.slice(0,40); + const zone=typeof payload.zoneName==='string'?payload.zoneName:typeof payload.zone==='string'?payload.zone:undefined; + if(zone)out.zone=zone.slice(0,120); + if(typeof payload.level==='number'&&Number.isFinite(payload.level))out.level=payload.level; + if(typeof payload.isBot==='boolean')out.isBot=payload.isBot; + return out; +} + +export async function GET(){ + try{ + const [events,commands]=await Promise.all([ + recentAleEvents(env.DB!,80), + recentAleCommands(env.DB!,30) + ]); + const newest=events[0]; + const now=Date.now(); + const lastEventAt=newest?.at??null; + const ageMs=lastEventAt?Math.max(0,now-Date.parse(lastEventAt)):null; + const recentWindow=events.filter(e=>now-Date.parse(e.at)<5*60_000); + return Response.json({ + ok:true, + ale:{ + lastEventAt, + ageMs, + activeRecently:recentWindow.length>0, + bridgeReady:events.some(e=>e.kind==='bridge.ready') + }, + playerbots:{ + observedBots:new Set(recentWindow.flatMap(e=>{ + const p=(e.payload&&typeof e.payload==='object'?e.payload:{}) as Record; + if(p.isBot!==true)return []; + if(typeof p.characterGuid==='number')return [`guid:${p.characterGuid}`]; + if(typeof p.name==='string'&&p.name.trim())return [`name:${p.name.toLowerCase()}`]; + return []; + })).size + }, + commands:{ + pending:commands.filter(c=>c.status==='pending').length, + dispatched:commands.filter(c=>c.status==='dispatched').length, + completed:commands.filter(c=>c.status==='completed').length, + failed:commands.filter(c=>c.status==='failed').length + }, + events:events.slice(0,30).map(safeEvent) + },{headers:{'Cache-Control':'no-store'}}); + }catch{ + return Response.json({ + ok:false, + ale:{lastEventAt:null,ageMs:null,activeRecently:false,bridgeReady:false}, + playerbots:{observedBots:0}, + commands:{pending:0,dispatched:0,completed:0,failed:0}, + events:[] + },{status:200,headers:{'Cache-Control':'no-store'}}); + } +} diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/app/chatgpt-auth.ts @@ -0,0 +1,90 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + userId: string; + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_ID_HEADER = "oai-authenticated-user-id"; +const USER_EMAIL_HEADER = "oai-authenticated-user-email"; +const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; +const USER_FULL_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; +const SIGN_IN_PATH = "/signin-with-chatgpt"; +const SIGN_OUT_PATH = "/signout-with-chatgpt"; +const CALLBACK_PATH = "/callback"; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + const userId = requestHeaders.get(USER_ID_HEADER); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!userId || !email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + userId, + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = "/"): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return "/"; + + let url: URL; + try { + url = new URL(value, "https://app.local"); + } catch { + return "/"; + } + if (url.origin !== "https://app.local") return "/"; + if (isReservedAuthPath(url.pathname)) return "/"; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..99802d5 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,8 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@custom-variant dark (&:is(.dark *)); +@theme inline {--color-background:var(--background);--color-foreground:var(--foreground);--color-card:var(--card);--color-card-foreground:var(--foreground);--color-popover:var(--card);--color-popover-foreground:var(--foreground);--color-primary:var(--primary);--color-primary-foreground:#fff;--color-secondary:#1b2a3c;--color-secondary-foreground:var(--foreground);--color-muted:#14202e;--color-muted-foreground:var(--muted);--color-accent:#1c3047;--color-accent-foreground:#fff;--color-destructive:#f27583;--color-border:var(--border);--color-input:var(--border);--color-ring:#77a7f9;--color-sidebar:var(--sidebar);--color-sidebar-foreground:var(--foreground);--color-sidebar-accent:#1b2c40;--color-sidebar-accent-foreground:#e2bd78;--color-sidebar-border:var(--border);--color-sidebar-ring:#77a7f9;--radius-lg:8px;--radius-md:6px;--radius-sm:4px;} +:root,.dark {--background:#090f18;--foreground:#dce5f1;--card:#101b29;--primary:#3876e6;--muted:#96a8be;--border:#253345;--sidebar:#0c1521;color-scheme:dark;} +*{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--foreground);font-family:Arial,Helvetica,sans-serif;font-size:16px}button,input,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed}h1,h2,h3,p{margin:0}h1{font:500 34px Georgia,serif;letter-spacing:.01em}h2{font-size:17px;font-weight:600}h3{font-size:16px;font-weight:600}small{font-size:13px;color:var(--muted)}svg{flex-shrink:0}.app-main{flex:1;min-width:0}.brand{display:flex;align-items:center;gap:12px;padding:27px 22px 25px}.brand-mark{font:38px Georgia,serif;color:#e3bb70}.brand strong{font:26px Georgia,serif;color:#e3bb70}.brand small{display:block;letter-spacing:2px;font-size:10px;margin-top:6px}.nav-section{color:#6f829b;letter-spacing:1.8px;font-size:11px;padding:24px 16px 10px}.nav-button{height:45px!important;margin-bottom:4px;padding:0 14px!important;font-size:14px!important}.nav-button svg{width:18px!important;height:18px!important}.sidebar-bottom{border-top:1px solid var(--border);padding:20px;margin:16px 8px 0;line-height:1.6;font-size:13px;color:var(--muted)}.topbar{height:65px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);padding:0 32px;background:#0d1622}.crumb{color:var(--muted);font-size:14px;display:flex;align-items:center;gap:12px}.top-right{display:flex;align-items:center;gap:16px;font-size:13px}.sim-badge{color:#e3bb70;border:1px solid #715b30;background:#30271b;padding:5px 10px;border-radius:5px;font-size:12px;white-space:nowrap}.content{padding:30px 32px;max-width:1800px;margin:auto}.page-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c7a667;text-transform:uppercase;margin-bottom:9px}.subtitle{color:var(--muted);font-size:14px;margin-top:9px;line-height:1.6}.actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.panel{background:linear-gradient(125deg,#121e2d,#0e1824);border:1px solid var(--border);border-radius:8px;overflow:hidden}.panel-head{padding:18px 21px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--border)}.panel-head h2{display:flex;gap:10px;align-items:center}.panel-head svg{color:#93a9c8;width:18px}.panel-body{padding:21px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:23px}.stat{padding:21px;position:relative}.stat-label{font-size:14px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}.stat-value{font-size:32px;font-weight:600;margin:13px 0 7px;font-variant-numeric:tabular-nums}.stat small{font-size:12px}.stat svg{color:#7aa4df;width:20px}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,1fr);gap:22px}.span-all{grid-column:1/-1}.director-strip{background:linear-gradient(110deg,#243143,#141f2c);border-color:#526072;display:flex;justify-content:space-between;align-items:center;gap:20px;padding:24px;margin-bottom:23px}.director-strip h2{font:22px Georgia,serif;margin-bottom:9px}.director-strip p{font-size:14px;line-height:1.6;color:#b0bfd1}.director-icon{width:48px;height:48px;color:#e3bb70;flex-shrink:0}.director-content{display:flex;align-items:center;gap:20px}.tag{display:inline-block;border:1px solid #314a67;border-radius:4px;padding:3px 7px;font-size:12px;color:#9cbdee;background:#192b42}.tag.online{color:#80d9af;border-color:#285e49;background:#15372d}.tag.paused{color:#e0bf78;border-color:#5a4c2d;background:#302a1f}.tag.offline,.tag.retired{color:#a1aec1;background:#202937;border-color:#394251}.event-row{display:grid;grid-template-columns:51px 77px 1fr;gap:12px;align-items:start;font-size:14px;line-height:1.5;padding:15px 20px;border-bottom:1px solid #202d3c}.event-row:last-child{border:0}.event-time{font:12px monospace;color:#788da6;padding-top:4px}.event-kind{font-size:11px;line-height:24px;color:#98b9ef;text-transform:capitalize}.event-kind.error{color:#fb8994}.event-kind.memory{color:#b8a5e8}.event-kind.lifecycle{color:#7ec9aa}.bot-name{display:flex;align-items:center;gap:12px}.avatar{width:37px;height:40px;border:1px solid #526084;border-radius:7px;display:grid;place-items:center;font:20px Georgia,serif;color:#c7b7ee;background:linear-gradient(140deg,#333555,#19263b)}.avatar.Warrior,.avatar.Paladin{color:#e3bb70;border-color:#786040;background:linear-gradient(140deg,#453a2b,#282b32)}.avatar.Rogue{color:#88cba9;border-color:#3b715e}.bot-name strong{font-size:14px}.bot-name small{display:block;margin-top:4px;font-size:12px}.data-table th{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#8297b1;padding:14px 20px}.data-table td{padding:15px 20px;font-size:14px}.data-table tbody tr{cursor:pointer}.data-table tbody tr:hover{background:#192738}.recipe-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.recipe-card{padding:22px;cursor:pointer;text-align:left;width:100%;transition:border-color .2s}.recipe-card:hover{border-color:#7093c5}.recipe-card h3{margin:14px 0 10px}.recipe-card p{color:var(--muted);font-size:14px;line-height:1.6;min-height:46px}.recipe-card .tags{margin-top:18px;display:flex;gap:6px;flex-wrap:wrap}.recipe-symbol{width:40px;height:40px;border:1px solid #4b5674;background:#202c40;display:grid;place-items:center;border-radius:8px;color:#b5c9eb}.section-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.field{display:flex;flex-direction:column;gap:8px;font-size:14px}.field.full{grid-column:1/-1}.field input,.field textarea{background:#0b1420;border:1px solid #35465c;border-radius:5px;padding:10px;width:100%;color:#e1e9f4}.field textarea{min-height:84px;resize:vertical}.field [data-slot=select-trigger]{width:100%;height:42px;background:#0b1420}.form-footer{display:flex;justify-content:flex-end;gap:10px;margin-top:23px}.range-label{display:flex;justify-content:space-between;font-size:14px;margin-bottom:13px}.range-row{margin:18px 0}.choice-row{display:flex;align-items:center;gap:10px;font-size:14px}.choices{display:grid;grid-template-columns:1fr 1fr;gap:14px}.detail-header{padding:24px 0;display:flex;gap:16px;align-items:center}.detail-header .avatar{width:57px;height:63px;font-size:30px}.detail-header h2{font:27px Georgia,serif}.detail-block{padding:20px 0;border-bottom:1px solid var(--border)}.detail-block h3{margin-bottom:13px}.detail-block p{font-size:14px;color:#b1bfd0;line-height:1.7}.memory{padding:13px;background:#0c1622;border-left:2px solid #8c79b5;margin:12px 0}.memory small{display:block;margin-top:7px}.list-muted{color:var(--muted);font-size:14px;line-height:1.7}.zone-row{display:flex;align-items:center;gap:15px;font-size:14px;margin-bottom:20px}.zone-row .bar{flex:1;height:5px;background:#243449;border-radius:5px}.zone-row .bar i{display:block;height:100%;background:#759acc;border-radius:5px}.zone-row .zone-label{width:116px}.engine-box{display:flex;gap:14px;padding:18px;border:1px solid var(--border);border-radius:6px;margin-bottom:15px}.engine-box svg{color:#d2af73}.engine-box p{font-size:14px;color:var(--muted);line-height:1.6;margin-top:6px}.notice{padding:15px 18px;background:#1e2939;border:1px solid #3b506a;border-radius:6px;font-size:14px;line-height:1.6;color:#b7c8df;margin-bottom:20px}.empty-note{padding:32px;text-align:center;color:var(--muted)}.toolbar{display:flex;gap:12px;align-items:center;margin-bottom:20px}.toolbar input{max-width:330px}.health-row{display:flex;justify-content:space-between;padding:12px 0;font-size:14px;border-bottom:1px solid var(--border)}.health-row span{color:var(--muted)}.status-message{margin-top:15px;color:#b7c8df;font-size:14px}.fineprint{font-size:12px;line-height:1.7;color:#8296b0;margin-top:16px}.mobile-trigger{display:none}.screen-loading{padding:40px}.mobile-zone{display:none}@media(max-width:1100px){.content{padding:24px}.dashboard-grid{grid-template-columns:1fr}.stats{gap:10px}.stat{padding:16px}.section-grid{grid-template-columns:1fr}.director-strip{align-items:flex-start}.topbar{padding:0 24px}}@media(max-width:767px){.mobile-trigger{display:inline-flex}.content{padding:20px 15px}.topbar{padding:0 15px}.top-right>span:last-child{display:none}.page-heading{align-items:flex-start;flex-direction:column;gap:15px}h1{font-size:29px}.stats{grid-template-columns:1fr 1fr}.stat-value{font-size:27px}.director-strip{flex-direction:column;padding:18px}.director-icon{display:none}.recipe-grid,.form-grid{grid-template-columns:1fr}.field.full{grid-column:auto}.event-row{grid-template-columns:42px 1fr;gap:8px}.event-kind{display:none}.recipe-card p{min-height:0}.topbar .crumb{font-size:12px}.panel-head{padding:16px}.panel-body{padding:17px}} +.gm-preview{font-family:monospace;font-size:13px;line-height:1.8;margin:20px 0;padding:16px;background:#080e17;border-left:2px solid #688ee3}.gm-preview span{color:#79a9ff}.gm-preview code{color:#b7bfce}.console-output{min-height:390px;max-height:570px;overflow:auto;background:#060c14;padding:25px;font:14px/1.7 monospace}.console-welcome{color:#79a7f5;letter-spacing:1px;margin-bottom:20px}.console-welcome span{color:#a0adc1;font-size:12px;letter-spacing:0}.console-muted{color:#788ba6;margin-bottom:20px}.console-entry{border-top:1px solid #1c2737;padding:14px 0;color:#9ccef2}.console-entry pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 monospace;margin-top:8px}.console-error{color:#ec8e9c}.console-input{display:flex;gap:10px;align-items:center;padding:16px;background:#101b29}.console-input>span{color:#83adf8;font:20px monospace}.console-input input{font-family:monospace}.gm-shortcut{display:flex;justify-content:space-between;gap:15px;border:1px solid #2b3a4e;border-radius:5px;padding:12px;width:100%;margin-bottom:10px;font-size:14px;text-align:left}.gm-shortcut:hover{background:#203047}.gm-shortcut code{font-size:12px;color:#8eafe4} +*,::before,::after{border-color:var(--border)} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2e0c9e1 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AzerothAI | Living Realm", + description: "A control plane for bot identities, personalities and stories.", + other: { + "codex-preview": "development", + }, + icons: { + icon: "/favicon.svg", + shortcut: "/favicon.svg", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..90d8ad8 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,143 @@ +'use client'; +import { useState,useEffect,useCallback } from 'react'; +import { LayoutDashboard,Users,BookOpen,Compass,Server,Activity,Brain,Sparkles,Plus,Play,Pause,ArrowRight,Shield,Terminal,ScrollText,Radio,RefreshCw,Heart,MapPin,Network,Copy,Send,House,Globe2,Moon,Sun,Skull,Clock3 } from 'lucide-react'; +import { SidebarProvider,Sidebar,SidebarHeader,SidebarContent,SidebarMenu,SidebarMenuItem,SidebarMenuButton,SidebarFooter,SidebarTrigger } from '@/components/ui/sidebar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription } from '@/components/ui/dialog'; +import { Sheet,SheetContent,SheetHeader,SheetTitle,SheetDescription } from '@/components/ui/sheet'; +import { AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction } from '@/components/ui/alert-dialog'; +import { Tabs,TabsList,TabsTrigger,TabsContent } from '@/components/ui/tabs'; +import { Select,SelectTrigger,SelectValue,SelectContent,SelectItem } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; +import { Table,TableHeader,TableRow,TableHead,TableBody,TableCell } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Toaster,toast } from 'sonner'; +import type { State,Bot,Recipe,RealmEvent,Activity as BotActivity } from '@/core/types'; +const NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Realm Director',Compass],['World & Housing',House],['Bots',Users],['Bot Recipes',BookOpen],['Memory & Bonds',Heart],['Event Stream',Activity],['GM Console',Terminal],['Server & Models',Server]] as const; +const ACTIVITIES:BotActivity[]=['explore','quest','socialize','trade','rest']; +function Choice({value,onChange,items,label}:{value:string;onChange:(v:string)=>void;items:{value:string;label:string}[];label:string}){return } +const options=(items:string[])=>items.map(value=>({value,label:value})); +function Avatar({bot}:{bot:Bot}){return } +function Panel({title,icon:Icon,children,action,className=''}:any){return

{Icon&&}{title}

{action}
{children}
} +function Events({events}:{events:RealmEvent[]}){return
{events.length?[...events].reverse().map(e=>
{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}{e.kind}{e.message}
):
No events match this filter.
}
} +export default function Home(){ + const [state,setState]=useState(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState({}),[monitor,setMonitor]=useState(null); + const refresh=useCallback(async()=>{try{const r=await fetch('/api/state');const d:any=await r.json();if(!r.ok)throw new Error(d.error);setState(d);setError('');}catch(e){setError(e instanceof Error?e.message:'Could not load realm');}},[]); + useEffect(()=>{void refresh();void fetch('/api/health').then(r=>r.json()).then(setCapabilities).catch(()=>{});const timer=setInterval(refresh,5000);return()=>clearInterval(timer);},[refresh]); + useEffect(()=>{if(page!=='World Monitor')return;let cancelled=false;const load=async()=>{try{const r=await fetch('/api/world-monitor');const d=await r.json();if(!cancelled)setMonitor(d);}catch{if(!cancelled)setMonitor(null);}};void load();const timer=setInterval(load,3000);return()=>{cancelled=true;clearInterval(timer);};},[page]); + const act=useCallback(async(action:any)=>{setBusy(true);try{const r=await fetch('/api/actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(action)});const d:any=await r.json();if(!r.ok)throw new Error(d.error);await refresh();return d;}catch(e){toast.error(e instanceof Error?e.message:'Action failed');await refresh();throw e;}finally{setBusy(false);}},[refresh]); + const perform=(action:any)=>{void act(action).catch(()=>{});}; + useEffect(()=>{const context=(document as any).modelContext;if(!context?.registerTool)return;const controller=new AbortController();const register=(tool:any)=>{try{Promise.resolve(context.registerTool(tool,{signal:controller.signal})).catch(()=>{});}catch{}}; + register({name:'azerothai_read_realm',description:'Read current bot identities, recipes, director settings and recent events.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true},execute:async()=>{const r=await fetch('/api/state');if(!r.ok)throw new Error('Realm unavailable');return r.json();}}); + register({name:'azerothai_create_bot',description:'Create an offline bot identity; does not spawn it in game.',inputSchema:{type:'object',properties:{name:{type:'string'},role:{type:'string'},race:{type:'string'},recipeId:{type:'string'},goal:{type:'string'}},required:['name','role','race','recipeId','goal'],additionalProperties:false},annotations:{readOnlyHint:false},execute:async(input:any)=>act({...input,type:'createBot'})});return()=>controller.abort();},[act]); + function nav(p:string){setPage(p);setQuery('');setFilter('all');} + const selected=state?.bots.find(b=>b.id===botId); + const visible=state?.bots.filter(b=>(`${b.name} ${b.role} ${b.zone}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||b.status===filter))??[]; + function botTable(bots:Bot[]){return CharacterStatusLocationRecipe{bots.map(b=>setBotId(b.id)}>
Lv. {b.level} · {b.race} {b.role}
{b.status}{b.zone}{b.personality.name.replace('The ','')}
)}
} + return
A
AzerothAIYOUR WORLD. ALIVE.
REALM MANAGEMENT
{NAV.map(([name,Icon])=>nav(name)}>{name})}

Realm administrator

Prototype 0.1 · Private realm

“What if Azeroth never felt empty?”

Living Realm / {page}
{capabilities.aleActiveRecently?'AZEROTHCORE LIVE':state?.settings.connector==='azerothcore'?'AzerothCore bridge':'SIMULATION REALM'}GM workspace
+
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}

{page==='Dashboard'?'Your realm, alive.':page}

{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}

{page==='Bot Recipes'?:<>}
+ {error&&
{error}
} + {!state?
{[1,2,3,4].map(n=>)}
:<> + {page==='Dashboard'&&<>
{[[Users,'Active bots',state.bots.filter(b=>b.status==='online').length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

{state.director.zone} · {state.director.brief}

nav('Bots')}>Manage bots }>{botTable(state.bots.slice(0,5))}
{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

Bot locations from the selected connector. Simulation locations are illustrative.

{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}

{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}

nav('Event Stream')}>View stream }>{}

Set the scene. Announce a gathering. Move a character to the next chapter of their story.

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} + {page==='World Monitor'&&} + {page==='Bots'&&<>
setQuery(e.target.value)}/>{visible.length} characters
{visible.length?botTable(visible):
No matching bots. Create a character or adjust your filters.
}
} + {page==='Bot Recipes'&&<>
Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.
{state.recipes.map((r,i)=>)}
} + {page==='Realm Director'&&} + {page==='Memory & Bonds'&&
{state.bots.map(b=>setBotId(b.id)}>Open profile}>

{b.memories.length} memories · {b.relationships.length} relationships

{b.memories.at(-1)?.text??'No memories yet.'}

{b.relationships.map(r=>

{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity
{r.note}

)}
)}
} + {page==='Event Stream'&&} + {page==='Server & Models'&&} + {page==='GM Console'&&} + } +

AzerothAI shapes identity and intent. AzerothCore owns the rules of the world.

+ Create a characterStart with an identity and recipe. Spawn when you’re ready.{state&&setCreateOpen(false)}/>} + {recipe?'Edit bot recipe':'Create bot recipe'}Traits guide high-level decisions. They never override game rules.setRecipeOpen(false)}/> + {if(!o)setBotId(null)}}>Bot profileIdentity, intent, and the story so far.{selected&&state&&} + {if(!o)setConfirm(null)}}>{confirm?.title}{confirm?.description}Cancel{if(confirm)perform(confirm.action);setConfirm(null)}}>Confirm
; +} +function CreateBot({state,act,busy,close}:any){const [name,setName]=useState(''),[role,setRole]=useState('Priest'),[race,setRace]=useState('Human'),[recipeId,setRecipeId]=useState(state.recipes[0].id),[goal,setGoal]=useState(`Support ${state.director.experience.toLowerCase()} in ${state.director.zone}`);return
{e.preventDefault();try{await act({type:'createBot',name,role,race,recipeId,goal});toast.success('Character created');close();}catch{}}}>