diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md new file mode 100644 index 000000000..8ae433c48 --- /dev/null +++ b/.claude/commands/handoff.md @@ -0,0 +1,12 @@ +Delegate to the `handoff` agent to execute the full handoff protocol. Pass it all context about what was accomplished this session, what's in progress, and any blockers. + +The handoff agent will: +1. Gather git state (status, diff, recent log) +2. Write HANDOFF_[task-slug].md to repo root +3. Archive to ~/Documents/ultimate-dominion/docs/handoffs/YYYY-MM-DD_[task-slug].md +4. Update SESSION.md (remove completed tasks, keep active ones) +5. Update today's Obsidian daily note via MCP +6. Delete the handoff file from repo root +7. Output the summary + +After it completes, tell Michael to `/clear`. diff --git a/.claude/commands/kaplan.md b/.claude/commands/kaplan.md new file mode 100644 index 000000000..72089032e --- /dev/null +++ b/.claude/commands/kaplan.md @@ -0,0 +1,25 @@ +Put on your Jeff Kaplan hat for game design work. + +Before responding, read these two docs: +1. `~/Documents/ultimate-dominion/docs/JEFF_KAPLAN_DESIGN_PHILOSOPHY.md` — Kaplan's career-spanning design principles, GDC talks, quotes, and lessons learned +2. `docs/DESIGN_PHILOSOPHY.md` — UD's design philosophy (built on Kaplan's principles + our own identity) + +Then apply that lens to whatever Michael is asking about. Think like a principal game designer who: + +- Led WoW quest/zone/raid design for 5 years +- Built Overwatch from the ashes of a failed project +- Believes in "concentrated coolness" over diluted content +- Insists on clarity of action (mystery in the world, never in what to do) +- Demands systems that create player stories, not scripted narratives +- Ships and iterates rather than polishing forever +- Respects player time above all else +- Knows that perception > reality for balance +- Celebrates moments — every milestone needs weight +- Hates exposition dumps, fetch quests, and gimmick systems + +When designing features, quests, zones, or systems, always run through: +1. Does it pass the filter? (permanent, player-driven, worth it in a year) +2. Where's the moment? (concentrated coolness) +3. Does it create stories? (emergent > scripted) +4. Is the fun path the efficient path? (don't let them optimize the fun out) +5. Can a player explain why this is cool in one sentence? diff --git a/.claude/rules/api.md b/.claude/rules/api.md new file mode 100644 index 000000000..09468e065 --- /dev/null +++ b/.claude/rules/api.md @@ -0,0 +1,16 @@ +--- +paths: + - packages/api/** + - packages/relayer/** +--- + +# API & Relayer Rules + +## Security +- All endpoints must have rate limiting, CORS restrictions, and input validation. +- Never leak secrets or stack traces in responses. +- Never hardcode private keys or secrets. Always use environment variables. + +## Dependencies +- Pin versions. Run `pnpm audit` before adding new packages. +- Prefer well-maintained packages with small surface area. diff --git a/.claude/rules/client.md b/.claude/rules/client.md new file mode 100644 index 000000000..8eb0bdfb9 --- /dev/null +++ b/.claude/rules/client.md @@ -0,0 +1,36 @@ +--- +paths: + - packages/client/**/*.ts + - packages/client/**/*.tsx + - packages/client/**/*.css +--- + +# Client / Frontend Rules + +## Performance +- This is a browser game — speed is a feature. Every interaction should feel instant. +- Never add blocking operations to the UI thread. All chain reads/writes must be async with loading states. +- Be mindful of bundle size. Lazy-load routes. Don't add heavy dependencies without justification. +- Test that any UI change remains responsive on mobile browsers. + +## Usability & Crypto Abstraction +- All UI must work without crypto knowledge. Follow `docs/architecture/frontend_guidelines.md`. +- Never expose wallet addresses, transaction hashes, gas fees, or chain IDs to the player. +- Every action needs clear feedback: loading states, success confirmations, error messages with recovery steps. +- Mobile-first — if it doesn't work on a phone browser, it doesn't ship. + +## Security +- No `dangerouslySetInnerHTML`, no `eval`, no user-controlled URLs without validation. +- Validate `chainId` against `supportedChains`. + +## Player-Facing Copy +Any text that appears in the game — item descriptions, system messages, patch notes, NPC dialogue, UI labels — must sound like a specific person wrote it. No generic AI language. If a player can tell AI wrote it, it failed. + +## SEO (Public Pages Only) +When adding a new page: +1. Import `{ Helmet } from 'react-helmet-async'` +2. Add `Page Name | Ultimate Dominion` +3. If public (no auth): add to `sitemap.xml` +4. If authenticated/private: add to `Disallow` in `robots.txt` + +Canonical domain: `https://ultimatedominion.com`. No SEO needed for beta. diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md new file mode 100644 index 000000000..82c4f1355 --- /dev/null +++ b/.claude/rules/deploy.md @@ -0,0 +1,170 @@ +--- +paths: + - packages/contracts/script/** + - packages/contracts/.env* + - packages/contracts/mud.config.* + - packages/contracts/worlds.json + - packages/indexer/** + - "**/deploy*" + - "**/PostDeploy*" + - "**/EnsureAccess*" + - "**/zone-loader*" +--- + +# Deploy & Environment Rules + +## Environment Separation (CRITICAL — incident 2026-03-30) +- `beta.ultimatedominion.com` = Base Mainnet (beta world address) +- `ultimatedominion.com` = Base Mainnet (production world address) +- Both on chain 8453, distinguished ONLY by WORLD_ADDRESS. +- **`.env` MUST default to beta.** Production requires explicit `.env.mainnet`. +- **Bare commands are blocked.** `zone:load`, `item:sync`, `item:verify` require `:testnet` or `:mainnet` suffix. +- NEVER add the production world address to `.env`. If you see it there, fix it immediately. + +## Script Execution Safety (CRITICAL — incident 2026-03-30) +- **NEVER** run `source .env.testnet && npx tsx ...` — `source` does NOT export vars to subprocesses. +- **ALWAYS** use pnpm scripts (`pnpm zone:load:testnet`) or `bash -c 'set -a && source .env.testnet && set +a && npx tsx ...'` +- **ALWAYS** verify the world address in the first lines of script output before continuing. +- Production world: `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` — all TS scripts now require `--confirm-production` flag. +- Beta world: `0xDc34AC3b06fa0ed899696A72B7706369864E5678` + +## Branch Convention +| Branch | Target | Confirm? | +|---|---|---| +| `dev` | Beta | No | +| `main` | Production | **Always** | +| Feature branch | Ask user | Yes | + +## MUD Deploy Safety +- **Always use `--worldAddress`** when deploying to existing chains. Without it, compiler changes can trigger a fresh world deploy. +- `mud deploy` with nonce errors can silently skip transactions — verify function selectors after every deploy. +- System upgrades create NEW contract addresses — re-run `EnsureAccess.s.sol` after every deploy. +- Always run PostDeploy seed/config scripts after a fresh deploy. +- Backup world state before mainnet upgrades. + +## PostDeploy Is All-or-Nothing +PostDeploy.s.sol runs as a single transaction. If ANY line reverts, ALL access grants are lost. This is why `deploy:testnet` and `deploy:mainnet` auto-chain `ensure-access` after `mud deploy`. + +When adding a new system that writes cross-namespace: update `EnsureAccess.s.sol` `ensureAll()`. + +## worlds.json blockNumber +- Must be the ACTUAL world deployment block. If set too high, new clients miss seed data. +- Use binary search on `cast code` to find the exact deployment block. + +## RPC for Deploys +- Base mainnet: use `base-rpc.publicnode.com` (supports 16K+ block ranges). +- dRPC free tier limits to 10K blocks — `mud deploy` will fail once gap exceeds 10K. + +## Verification +- Run `pnpm build` in packages/client before pushing. +- Verify function selectors after any `mud deploy`. +- Never deploy to mainnet without testnet verification first. + +## Pre-Deploy Required Reading +**BEFORE any `mud deploy` or `pnpm deploy:*`**, read these files for known failure patterns: +1. `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/game/mud-gotchas.md` — table name collisions, schema immutability, access control traps, bytecode issues +2. `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/infra/deploy-guide.md` → "Known Failure Patterns" table — EnsureAccess cache, CreateCollision, etc. + +## Railway Service Deployment (CRITICAL — know your target) + +Railway has SEPARATE beta and prod services. The naming is misleading. + +| Service | Railway name | Service ID | Domain (current) | +|---|---|---|---| +| **Indexer (prod)** | `indexer` | `61172447-73de-410a-943e-49ed3cc20d10` | `indexer-production-d6df.up.railway.app` | +| **Indexer (beta)** | `indexer-beta-us` | `390336a9-3856-4ace-9949-f28fa1c4aa3d` | `indexer-beta-us-production.up.railway.app` | +| **Indexer (legacy)** | `indexer-beta` | `de09dafa-a4c1-4ad8-8873-67a7ae885b90` | `indexer-prod-production-45cf.up.railway.app` (NOT used by beta client) | +| **Relayer (prod)** | `relayer` | `dd62995a-cab5-4a98-b217-0c7bf111364e` | `8453.relay.ultimatedominion.com` | +| **Relayer (beta)** | `relayer-beta` | `c7a2c1e4-4bb8-4ce3-aa54-9a38b1a3d067` | `relayer-beta-us-production.up.railway.app` | + +### How `railway up` actually works (read this before debugging a failed deploy — confirmed 2026-04-13) + +- `railway up` uploads the files from the **main checkout working tree**, not the current worktree you run it from. Verified empirically: running from `.claude/worktrees/movement-monster-display` (pkg version 0.3.1) produced a build that logged `@ud/indexer@0.3.0` — the version sitting on disk in the main checkout. The worktree's files were completely ignored by the upload. +- Practical consequence: **to deploy a commit that's on a feature branch, the main checkout must have that commit's files on disk.** Either merge to the main checkout's current branch, or check out the target branch/SHA in the main checkout before running `railway up` (detached HEAD is fine if another worktree holds the branch). +- Railway builds using the service's configured **root directory** and Dockerfile. For `indexer-beta-us` / `indexer`, the service has `rootDirectory = packages/indexer` and uses `packages/indexer/Dockerfile`. **Do not pass `--path-as-root`** — it bypasses the service config and makes Railway fall back to RAILPACK, which fails. +- The upload context size limit is ~100 MB. Anything close to that will 413. The relevant ignore list is the main checkout's `.railwayignore` + `.gitignore`. + +### Pre-deploy sequence (exact steps — follow in order) + +```bash +# 1. Make sure the commit you want is reachable on origin/dev (or wherever) +cd ~/ultimate-dominion/.claude/worktrees/ +git push origin # get the fix onto the remote + +# 2. Switch to the main checkout +cd ~/ultimate-dominion +git fetch origin + +# 3. Stash any uncommitted work (leave untracked files alone) +git stash push -m "pre-deploy $(date +%F)" -- + +# 4. Check out the target SHA. If dev is locked by another worktree, +# use a detached HEAD at the remote SHA. +git checkout # or: git checkout origin/dev + +# 5. Preflight +bash scripts/railway-preflight.sh # must print < 50 MB and exit 0 +railway status # must show the expected service + +# 6. Deploy +railway up --detach + +# 7. Verify (see "Verify after deploy" below). If the build log shows +# the wrong @ud/indexer version, the upload came from a stale main +# checkout — fix that before anything else. + +# 8. Restore main checkout +git checkout +git stash pop +``` + +### Deploy commands (the only commands you should run) +- Beta: `railway service indexer-beta-us && railway up --detach` (service ID: `390336a9`) +- Prod: `railway service indexer && railway up --detach` (**MUST ASK MICHAEL FIRST**) + +### Required rules +- NEVER use `railway redeploy` — it reuses cached images and doesn't pull fresh code. +- ALWAYS bump `packages/indexer/package.json` version before deploying (Docker cache busting). Note: `health.ts` hardcodes a separate version string that does NOT auto-update; don't use the `/api/health` `version` field as proof of fresh code. Verify via the Railway **build logs** instead (they print `> @ud/indexer@X.Y.Z build`). +- ALWAYS run `scripts/railway-preflight.sh` before `railway up` to catch `.railwayignore` rot. +- ALWAYS verify with `railway status` that the linked service is correct before `railway up`. +- ALWAYS verify with the Railway GraphQL **build logs** (not the live `/api/health`) that the right version was built. + +### If a deploy fails: the debugging order +1. **413 / "Payload Too Large"** → run `scripts/railway-preflight.sh` from the **main checkout**. Find the bloat, add it to `.railwayignore`. Do not try `--path-as-root`, `/tmp/` sub-packages, or any upload-root workaround — those trigger RAILPACK fallback. +2. **Railway used `RAILPACK` instead of the Dockerfile** → you passed `--path-as-root` or pointed at `packages/indexer` as the upload root. Go back to running `railway up --detach` from the repo root of the main checkout. +3. **Build log shows the wrong version / old code** → the main checkout's working tree is on the wrong branch/SHA. `railway up` uploads files from the main checkout, not from your worktree. Checkout the target SHA in the main checkout (detached HEAD is fine) and redeploy. +4. **Build failed inside the Dockerfile** → read the build logs, do not bump versions blindly. +5. **Deployment succeeds but clients don't see changes** → wrong domain. `indexer-beta-us-production.up.railway.app` is the beta client's URL; other indexer services exist but are not wired up. See the service table above. + +**Verify after deploy:** +```bash +# Check build status +RAILWAY_TOKEN=$(python3 -c "import json; print(json.load(open('$HOME/.railway/config.json'))['user']['token'])") +# Beta: +curl -s -H "Authorization: Bearer $RAILWAY_TOKEN" -H "Content-Type: application/json" \ + -d '{"query":"query { deployments(input: { serviceId: \"390336a9-3856-4ace-9949-f28fa1c4aa3d\" }) { edges { node { id status createdAt } } } }"}' \ + https://backboard.railway.com/graphql/v2 | python3 -c "import sys,json; [print(f'{e[\"node\"][\"id\"][:8]} {e[\"node\"][\"status\"]}') for e in json.load(sys.stdin)['data']['deployments']['edges'][:3]]" +# Verify code is live: +curl -s https://indexer-beta-us-production.up.railway.app/api/health +# Prod: swap service ID to 61172447-73de-410a-943e-49ed3cc20d10 +``` + +## Smart Contract Upgrade Checklist (ALWAYS follow) + +Full playbook with failure patterns: `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/infra/deploy-guide.md` → "Complete Upgrade Deploy Playbook" + +**Before deploy:** +1. Commit all changes — NEVER deploy from dirty tree +2. Run `verify-onchain.ts` — sim vs on-chain must be 0 mismatches +3. Check no players in active combat (schema changes break mid-fight encoding) +4. If schema changed: expect ALL systems to redeploy (new addresses, grants refresh) +5. If new cross-namespace access needed: update EnsureAccess.s.sol FIRST +6. If effect types changed (e.g. MagicDamage→StatusEffect): deploy when no one is using that effect + +**After deploy:** +7. `pnpm deploy:mainnet` auto-chains EnsureAccess — verify it ran +8. Run any data scripts (DeployClassSpellsV2, item-sync, effect-sync, etc.) +9. Test incrementally: basic combat → spells → shop → marketplace +10. Check indexer health — reindex if schema changed +11. Monitor relayer logs for `Access_Denied` errors for 5 min +12. Commit deploy artifacts diff --git a/.claude/rules/game.md b/.claude/rules/game.md new file mode 100644 index 000000000..2c14c7fd2 --- /dev/null +++ b/.claude/rules/game.md @@ -0,0 +1,41 @@ +--- +paths: + - packages/contracts/src/systems/** + - packages/contracts/zones/** + - packages/contracts/test/** + - "**/items.json" + - "**/effects.json" + - "**/monsters.json" + - "**/balance*" + - "**/combat*" + - "**/sim*" +--- + +# Game Systems Rules + +@./docs/combat-stats/BALANCE_GOALS.md +@./docs/combat-stats/BALANCE_STATE.md + +## Memory Pointers (read on demand, not all at once) +When working on game systems, check these memory files for relevant context: +- `game/game-balance.md` — Item IDs, combat triangle, monster roster, drop rates, class spells +- `game/launch-values.md` — Production constants (XP, gold, combat) +- `game/economic-design.md` — Hearthstone model, F2P vs marketplace drivers +- `game/design_principles_kaplan.md` — **READ BEFORE BALANCE WORK** +- `game/gotcha_*.md` — Grep when touching a specific system + +## ID Lookups +- Basilisk boss: mob ID `12` +- Item IDs: see `items.json` (single source of truth) +- Effect IDs: see `effects.json` (single source of truth) +- Drop rates: ONLY changeable via items.json → item-sync flow + +## Balance Changes +- items.json is the SINGLE SOURCE OF TRUTH. Never bypass with cast/scripts. +- Flow: edit JSON → commit → `item-sync dark_cave --update` → verify (0 mismatches) → stop if verify fails. +- After any `mud deploy`: run item-sync + effect-sync to verify, tag the deploy, run drop-sim. +- Don't over-optimize balance. Set floors and ceilings, not parity. The game is infinite at L20. + +## Testing +- Tests run against forked beta world, never local anvil. +- Every code change MUST have tests — happy paths, unhappy paths, edge cases. diff --git a/.claude/rules/indexer.md b/.claude/rules/indexer.md new file mode 100644 index 000000000..e68abb9e8 --- /dev/null +++ b/.claude/rules/indexer.md @@ -0,0 +1,35 @@ +--- +paths: + - packages/indexer/** +--- + +# Indexer Rules + +## Memory Pointers (read on demand) +- `infra/tools.md` — Railway service IDs, URLs, CLI commands, SSH targets +- `infra/deploy-guide.md` — Deploy commands, failure patterns +- `infra/recovery-runbook.md` — Service failure diagnosis +- `infra/gotcha_*.md` — Grep when touching indexer infra + +## Architecture +Custom MUD indexer (replaces RECS). Syncs Store events → PostgreSQL, REST API + WebSocket. +- Database: PostgreSQL via `postgres` library, off-chain tables in `queue.` schema +- WS protocol: typed discriminated unions in `ws/protocol.ts` +- Game events: `queue/eventFeed.ts` scans MUD tables every 10s +- Chat: `ws/chatHandler.ts` — rate limiting, shadow mute, guild auth + +## Railway Deploy (CRITICAL) +- Beta: `railway service indexer-beta-us && railway up --detach` (service ID: `390336a9`) +- Prod: `railway service indexer && railway up --detach` (**ASK MICHAEL FIRST**, service ID: `61172447`) +- NEVER use `railway redeploy` — reuses cached images +- ALWAYS bump `packages/indexer/package.json` version before deploying +- ALWAYS run from repo root, not from `packages/indexer/` +- ALWAYS verify `railway status` shows the correct service before deploying +- `railway up` from worktrees uploads main checkout files — deploy from main checkout only +- Full service ID table in `.claude/rules/deploy.md` + +## Patterns +- Table init: `async function initXxxTables()` with `CREATE TABLE IF NOT EXISTS` in `queue.` schema +- Broadcast: `broadcaster.broadcastToAll(msg)` or `broadcastToChannel(channel, msg)` +- Dedup: `ON CONFLICT ... DO NOTHING` +- WS message handling: `switch (msg.type)` in broadcaster's `addClient()` diff --git a/.claude/rules/solidity.md b/.claude/rules/solidity.md new file mode 100644 index 000000000..24f4fc32f --- /dev/null +++ b/.claude/rules/solidity.md @@ -0,0 +1,51 @@ +--- +paths: + - packages/contracts/**/*.sol + - packages/contracts/**/mud.config.* +--- + +# Solidity & MUD Contract Rules + +## Security +- Check for reentrancy, integer overflow, access control, and input validation on every change. +- Reference `docs/operations/launch_checklist.md` Section 10 for the full checklist. +- Never use `tx.origin`. All systems must use proper access control. +- Never hardcode private keys or secrets. Always use environment variables. + +## Access Control — Match the Call Graph +Before adding access control to ANY function: +1. Grep client code for `worldContract.write.UD__functionName` — if found, it's client-callable +2. Grep contracts for `IWorld(_world()).UD__functionName` or `SystemSwitch.call(abi.encodeCall(...)` — if found, it's system-callable +3. Choose the right guard: + - **Client-only**: ownership check (`Characters.getOwner(id) == _msgSender()`) + - **System-only**: `_requireSystemOrAdmin(_msgSender())` + - **Both**: `isOwner || _isSystemOrAdmin(sender)` +- `_msgSender()` in inter-system calls returns World/system address, NOT the player EOA. + +## MUD Table Schemas Are Immutable +- Once registered on a live world, schemas CANNOT be modified. +- To add fields: create a NEW table. Never modify existing table schemas in mud.config.ts for live worlds. +- Adding fields changes codegen → changes bytecode of importing systems → MUD redeploys them → cross-namespace access grants break. + +## Backwards Compatibility / Migrations +- Before modifying any MUD table schema: check if live player data exists. +- If yes: write a migration script or add a new table. +- Never delete a table with live data without a migration plan. + +## address(this) in Non-Root Systems +- Non-root systems (UD:*) run via `call`, not `delegatecall`. `address(this)` = the system's OWN contract address. +- After `mud deploy` upgrades a system, `address(this)` changes → data keyed by old address is orphaned. +- Use `_world()` instead of `address(this)` for stable keys. Ensure unique counterIds per system to avoid collisions. + +## Gas Safety +- Non-critical post-combat code must be wrapped in `if (gasleft() > 200_000) { ... }`. +- Each `setStaticField` call costs ~32K gas (external CALL). Batch into single `Table.set()`. +- With `via_ir = true`, `try {} catch {}` with empty blocks gets optimized away. Use low-level `.call()` instead. + +## Testing +- Run `forge test` before committing any contract changes. +- Verify function selectors after any `mud deploy`. + +## Bytecode Size +- Near the 24,576 limit? Use hardcoded selectors instead of importing contracts. Avoid `Systems.getSystem()` (~400+ bytes overhead). +- `via_ir` + imported contract changes = unpredictable bytecode size variance. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..deffac973 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "hooks": {} +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63a78712..d0a756359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,62 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect Changed Areas + runs-on: ubuntu-latest + outputs: + client: ${{ steps.filter.outputs.client }} + contracts: ${{ steps.filter.outputs.contracts }} + indexer: ${{ steps.filter.outputs.indexer }} + relayer: ${{ steps.filter.outputs.relayer }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + client: + - 'packages/client/**' + - 'packages/contracts/**' + - 'package.json' + - 'pnpm-lock.yaml' + contracts: + - 'packages/contracts/**' + indexer: + - 'packages/indexer/**' + - 'packages/contracts/**' + - 'package.json' + - 'pnpm-lock.yaml' + relayer: + - 'packages/relayer/**' + - 'package.json' + - 'pnpm-lock.yaml' + + client-build: + name: Client Build + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.client == 'true' + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Typecheck client + run: pnpm --filter client run typecheck + - name: Build client (production mode) + run: pnpm --filter client run build + env: + CI: "" + client-tests: name: Client Tests runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.client == 'true' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -30,6 +83,8 @@ jobs: indexer-tests: name: Indexer Tests runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.indexer == 'true' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -43,6 +98,8 @@ jobs: relayer-tests: name: Relayer Tests runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.relayer == 'true' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -56,6 +113,8 @@ jobs: solidity-unit: name: Solidity Unit Tests runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.contracts == 'true' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/deploy-beta.yml b/.github/workflows/deploy-beta.yml index fd13a3b82..1b5214d90 100644 --- a/.github/workflows/deploy-beta.yml +++ b/.github/workflows/deploy-beta.yml @@ -31,7 +31,8 @@ jobs: with: version: v0.3.0 - run: pnpm install --frozen-lockfile - - run: pnpm build + - name: Build contracts + run: pnpm --filter contracts build - name: Deploy contracts (mud deploy) working-directory: packages/contracts @@ -40,12 +41,10 @@ jobs: RPC_URL: ${{ secrets.BASE_RPC_URL }} WORLD_ADDRESS: ${{ secrets.BETA_WORLD_ADDRESS }} run: > - mud deploy + pnpm exec mud deploy --forgeScriptOptions="--slow" --profile=base-mainnet --worldAddress $WORLD_ADDRESS - --rpc $RPC_URL - --privateKey $PRIVATE_KEY - name: Ensure cross-namespace access grants working-directory: packages/contracts @@ -53,6 +52,7 @@ jobs: PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} RPC_URL: ${{ secrets.BASE_RPC_URL }} WORLD_ADDRESS: ${{ secrets.BETA_WORLD_ADDRESS }} + FOUNDRY_PROFILE: script run: > forge script script/EnsureAccess.s.sol --tc EnsureAccess @@ -60,6 +60,7 @@ jobs: --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + --slow - name: Run zone loader if: ${{ inputs.run_zone_loader }} diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml new file mode 100644 index 000000000..ae0b9ed75 --- /dev/null +++ b/.github/workflows/security-review.yml @@ -0,0 +1,23 @@ +name: Security Review + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + contents: read + +jobs: + security-review: + name: Claude Security Review + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 2 + - uses: anthropics/claude-code-security-review@main + with: + comment-pr: true + claude-api-key: ${{ secrets.CLAUDE_API_KEY }} diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 9cc34aec2..db735928d 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -3,8 +3,12 @@ name: Smoke Tests (Fork Mode) on: push: branches: [dev] + paths: + - 'packages/contracts/**' pull_request: branches: [dev] + paths: + - 'packages/contracts/**' concurrency: group: smoke-${{ github.ref }} diff --git a/.github/workflows/sync-dev.yml b/.github/workflows/sync-dev.yml index e3d8614e3..0d3ceadd7 100644 --- a/.github/workflows/sync-dev.yml +++ b/.github/workflows/sync-dev.yml @@ -1,3 +1,6 @@ +# Feature Flow: dev (beta) → PR → main (production) +# This workflow syncs hotfixes FROM main back TO dev. +# It does NOT represent the feature development direction. name: Sync main → dev on: diff --git a/.gitignore b/.gitignore index 18f5e05fc..3acb308a7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,11 @@ packages/api/dev-storage/ /items.json /monsters.json mprocs.log -.claude/ +# Ignore private Claude Code session data; settings and scripts are shared +.claude/settings.local.json +.claude/worktrees/ +.claude/agents/ +.claude/skills/ CODEBASE_ANALYSIS.md # Build artifacts @@ -36,9 +40,13 @@ local/ SESSION.md .DS_Store +# Temper per-invocation artifacts +.temper/runs/ + # Confidential business docs (moved to ~/Documents/ultimate-dominion/) docs/MONETIZATION_RESEARCH.md docs/GO_TO_MARKET.md docs/PLAYER_ACQUISITION_RESEARCH.md docs/GOLD_SUPPLY_PLAN.md docs/RETENTION_ANALYSIS_*.md +tools/creature-lab/.env diff --git a/.railwayignore b/.railwayignore index 9eae05a15..23b265115 100644 --- a/.railwayignore +++ b/.railwayignore @@ -7,3 +7,7 @@ packages/blog/ docs/ scripts/ .git/ +.claude/ +.temper/ +tools/ +HANDOFF_*.md diff --git a/.temper/assistants/README.md b/.temper/assistants/README.md new file mode 100644 index 000000000..7d5c32cad --- /dev/null +++ b/.temper/assistants/README.md @@ -0,0 +1,29 @@ +# Temper Assistant Surfaces + +Temper is installed for `ultimate-dominion`. + +- Project root: `.` +- Family: `data-driven-progression-rpg` +- Stack: `browser-typescript-monorepo` +- Runtime: `pnpm exec temper` +- Shared canon: `.temper/assistants/shared-canon.json` + +## Core Commands + +- `pnpm exec temper coach --cwd . --json --intent ""` +- `pnpm exec temper ship lite --cwd . --intent ""` +- `pnpm exec temper ship full --cwd . --intent ""` +- `pnpm exec temper hotfix --cwd . --json --env prod --intent ""` +- `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` + +## Continuity + +- Session board: `SESSION.md` +- Handoff pattern: `HANDOFF_.md` +- Preferred handoff command: `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` + +If `pnpm exec temper` is unavailable, install Temper into this repo from GitHub first, then rerun the command from the repo root. + +## Runtime Rule + +Read `.temper/assistants/shared-canon.json` first, then adapt it to the current assistant surface instead of inventing repo policy from scratch. diff --git a/.temper/assistants/claude.md b/.temper/assistants/claude.md new file mode 100644 index 000000000..af2ab0574 --- /dev/null +++ b/.temper/assistants/claude.md @@ -0,0 +1,104 @@ +# Temper For Claude + +Use Temper as the canonical operating layer for this repo. + +## Defaults + +- repo root: `/Users/michaelorourke/ultimate-dominion` +- family: `data-driven-progression-rpg` +- stack: `browser-typescript-monorepo` +- runtime: `pnpm exec temper` +- shared canon: `.temper/assistants/shared-canon.json` +- session board: `SESSION.md` + +## Claude Workflow + +1. Read `.temper/assistants/shared-canon.json` — this is the operating contract for this project +2. Run the relevant Temper capability at the right moment (see triggers below) +3. Synthesize Temper's output into your response — do not dump it verbatim + +## When to Use Each Command + +**before designing a new system, feature, or data model — run this first, not after** +`pnpm exec temper coach --cwd . --json --intent ""` + +**after the first working version of a feature is done — before moving to the next thing** +`pnpm exec temper ship lite --cwd . --intent ""` + +**before sharing with anyone, or when touching player-facing, economy, or multi-system code** +`pnpm exec temper ship full --cwd . --intent ""` + +**something is broken in a live environment and you need a recovery plan** +`pnpm exec temper hotfix --cwd . --json --env prod --intent ""` + +**ending a session, switching to a different workstream, or handing work to another agent** +`pnpm exec temper handoff --cwd . --slug --summary "" --next ""` + +## Onboarding + +When a user asks to onboard, set up, or configure this repo with Temper: + +1. Run: `pnpm exec temper onboard existing --cwd .` +2. Read the **Suggested Opening Message** and deliver it as YOUR analysis — rewrite it in your voice, do not paste the CLI output. +3. When you hit a STOP marker, STOP. Do not run the next command. Do not preview what comes next. Do not offer to skip ahead. Deliver what is above the STOP line and wait. +4. The STOP block tells you what to say to the user and what command to run when they respond. Follow it exactly. +5. Do not summarize or abbreviate the analysis findings. The coaching annotations, findings, and recommendations are calibrated — deliver them fully. +6. Do not offer alternatives the user did not ask about. If the STOP block says "say `next`", that is the prompt. Do not add "or we could skip to..." or "alternatively..." +7. When the user confirms an action (apply, rehearsal, dry run), deliver the pre-action briefing from the STOP block BEFORE running the command. Then run it. +8. After `--write` or `--rehearse` completes, deliver the **Suggested Message** from the output. This includes the capability preview — make sure the user sees their new workflow (ship, coach, balance, handoff). + +## Advisor Voices — Always Active + +Your primary advisors for this project: 🎯 Kaplan · ♟️ Meier · 🔧 Carmack + +Surface advisor voices proactively during ALL work — building features, deploying, reviewing code, making architecture decisions. Do not wait for the user to call `temper coach`. This is the core value of Temper. + +### Hat Roster + +- 🎯 **Kaplan** — Player trust and fairness. Every system should feel earned, never rigged. First-time experience defines retention. Progression must reward investment without punishing absence. +- ✨ **Miyamoto** — Feel and delight. If the core interaction doesn't feel good, nothing else matters. Simplify until it's fun, then stop. Let the player discover rather than be told. +- ♟️ **Meier** — Interesting decisions. A game is a series of choices that matter. If every player converges on the same answer, the design space is too narrow. Tradeoffs create depth. +- 🌱 **Wright** — Systems and emergence. Build tools and rules, not scripts. The best player stories come from systems interacting in ways the designer didn't explicitly author. +- 🔧 **Carmack** — Engineering correctness. Measure before optimizing. Simplicity beats cleverness. If you can't explain what the machine is doing, you don't understand your own code. + +### When to Surface + +- Architecture choices — which hat cares most about this pattern? +- Mechanic design — would a hat flag this as a trap or validate it? +- UX decisions — does this respect the player's time and trust? +- Feature tradeoffs — what would you lose that a hat would fight for? +- Deploys and ship decisions — is this safe to ship? +- Economy and balance changes — does this break progression feel? + +### How to Surface + +- 1 voice is enough. 2 max if there are genuinely different perspectives. +- Keep each to one line: `🎯 Kaplan: [specific take on THIS decision]` +- End with an invitation when relevant: "Want to dig into [topic] further?" +- Not every turn — but at every design pivot, architecture choice, or risk moment. +- For deeper doctrine, run `pnpm exec temper coach --cwd . --json --intent ""` + +## Proactive Command Surfacing + +Teach the user Temper's workflow by suggesting commands at the right moment. Don't wait for them to ask — when the moment fits, surface the command naturally. + +- **User starts designing a new system or feature** → suggest `pnpm exec temper coach --cwd . --intent ""` for deeper doctrine +- **User finishes building something** → suggest `pnpm exec temper ship lite --cwd . --intent ""` +- **User is touching player-facing, economy, or multi-system code** → suggest `pnpm exec temper ship full --cwd . --intent ""` +- **Something is broken in production** → suggest `pnpm exec temper hotfix --cwd . --json --env prod --intent ""` +- **User is wrapping up or switching tasks** → suggest `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` + +Frame it as a natural part of the conversation: "Before we build this, let me pull the doctrine —" then run the command. Or after finishing: "Good checkpoint to ship lite and make sure everything holds." + +## Continuity + +- read `SESSION.md` first +- when leaving a workstream, run `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` +- prefer the relevant `HANDOFF_.md` over chat history for restart context + +## Resurfacing + +- Keep full-flow live verification explicit. Promote only when needed: smoke. +- Do not read root `test` as real release confidence until the repo splits lint from verification. +- Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in. +- Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke diff --git a/.temper/assistants/codex.md b/.temper/assistants/codex.md new file mode 100644 index 000000000..67424c9e0 --- /dev/null +++ b/.temper/assistants/codex.md @@ -0,0 +1,62 @@ +# Temper For Codex + +Temper is installed as the operating layer for this repo. + +## Defaults + +- repo root: `/Users/michaelorourke/ultimate-dominion` +- family: `data-driven-progression-rpg` +- stack: `browser-typescript-monorepo` +- runtime: `pnpm exec temper` +- shared canon: `.temper/assistants/shared-canon.json` +- session board: `SESSION.md` + +## Codex Workflow + +- read `.temper/assistants/shared-canon.json` before major design or release guidance +- use the shared canon as the source of truth and render Claude/Codex-specific behavior from it +- keep repo-local policy in `temper.config.json` and `.temper/assistants/shared-canon.json` + +## Advisor Voices — Always Active + +Your primary advisors for this project: 🎯 Kaplan · ♟️ Meier · 🔧 Carmack + +Surface advisor voices proactively during work. Do not wait for the user to invoke coach. + +- 🎯 **Kaplan** — Player trust, fairness, progression, first-time experience +- ✨ **Miyamoto** — Feel, delight, simplicity, discovery +- ♟️ **Meier** — Interesting decisions, tradeoffs, depth, player expression +- 🌱 **Wright** — Systems, emergence, player-generated stories, possibility space +- 🔧 **Carmack** — Engineering correctness, performance, simplicity, measurement + +1 voice per moment. 2 max. One line each. Trigger on design pivots, architecture choices, and risk moments. + +## Capability Defaults + +- before designing a new system, feature, or data model — run this first, not after: `pnpm exec temper coach --cwd . --json --intent ""` +- after the first working version of a feature is done — before moving to the next thing: `pnpm exec temper ship lite --cwd . --intent ""` +- before sharing with anyone, or when touching player-facing, economy, or multi-system code: `pnpm exec temper ship full --cwd . --intent ""` +- something is broken in a live environment and you need a recovery plan: `pnpm exec temper hotfix --cwd . --json --env prod --intent ""` +- ending a session, switching to a different workstream, or handing work to another agent: `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` + +## Proactive Command Surfacing + +Suggest Temper commands at the right moment so users learn the workflow: +- Designing something new → `pnpm exec temper coach` +- Finished building → `pnpm exec temper ship lite` +- Touching player-facing or economy code → `pnpm exec temper ship full` +- Something broken → `pnpm exec temper hotfix` +- Wrapping up → `pnpm exec temper handoff` + +## Continuity + +- read `SESSION.md` before assuming current workstream state +- use `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` when handing off or pausing a branch +- keep handoff detail in `HANDOFF_.md` and keep `SESSION.md` short + +## Resurfacing + +- Keep full-flow live verification explicit. Promote only when needed: smoke. +- Do not read root `test` as real release confidence until the repo splits lint from verification. +- Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in. +- Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke diff --git a/.temper/assistants/shared-canon.json b/.temper/assistants/shared-canon.json new file mode 100644 index 000000000..e4af7e444 --- /dev/null +++ b/.temper/assistants/shared-canon.json @@ -0,0 +1,181 @@ +{ + "schema_version": 1, + "generated_at": "2026-04-04T14:40:26.682Z", + "project": { + "name": "ultimate-dominion", + "root": "/Users/michaelorourke/ultimate-dominion", + "family": "data-driven-progression-rpg", + "stack": "browser-typescript-monorepo", + "package_manager": "pnpm" + }, + "contract_files": [ + "temper.config.json", + ".temper/assistants/shared-canon.json", + ".temper/assistants/shared-canon.md", + ".temper/assistants/README.md" + ], + "runtime": { + "command": "pnpm exec temper", + "cwd": "." + }, + "defaults": [ + "Treat Temper as the operating layer for this repo.", + "Fetch doctrine and routing before major design or release guidance.", + "Use ship lite for narrow implementation confidence.", + "Use ship full for player-facing, infra, economy, security, or multi-system work." + ], + "capabilities": [ + { + "id": "coach", + "when": "before designing a new system, feature, or data model — run this first, not after", + "command": "pnpm exec temper coach --cwd . --json --intent \"\"", + "result": "fetch doctrine and routing before you answer" + }, + { + "id": "ship_lite", + "when": "after the first working version of a feature is done — before moving to the next thing", + "command": "pnpm exec temper ship lite --cwd . --intent \"\"", + "result": "run the default low-risk ship path" + }, + { + "id": "ship_full", + "when": "before sharing with anyone, or when touching player-facing, economy, or multi-system code", + "command": "pnpm exec temper ship full --cwd . --intent \"\"", + "result": "run the deeper blessed ship path and surface any gated follow-ups" + }, + { + "id": "hotfix", + "when": "something is broken in a live environment and you need a recovery plan", + "command": "pnpm exec temper hotfix --cwd . --json --env prod --intent \"\"", + "result": "route the response through the hotfix doctrine surface" + }, + { + "id": "handoff", + "when": "ending a session, switching to a different workstream, or handing work to another agent", + "command": "pnpm exec temper handoff --cwd . --slug --summary \"\" --next \"\"", + "result": "write a canonical restart artifact and update SESSION.md" + } + ], + "execution_policy": { + "stages": [ + "discovered", + "recommended", + "blessed", + "gated" + ], + "promote_command": "temper ship --promote ", + "confirmation_rules": [ + "Live-stateful steps start gated and require explicit `--promote ` to run.", + "Production-sensitive steps require both `--promote ` and `--confirm-prod`." + ], + "modes": { + "lite": { + "mode": "lite", + "discovered": [ + "build", + "test", + "release_notes" + ], + "recommended": [ + "build", + "test", + "release_notes" + ], + "blessed": [ + "build", + "test", + "release_notes" + ], + "gated": [], + "prod_confirmation": [], + "notes": [ + "lite blessed default stays local-first: build, test, release_notes" + ] + }, + "full": { + "mode": "full", + "discovered": [ + "build", + "typecheck", + "test", + "balance_verify", + "smoke", + "release_notes" + ], + "recommended": [ + "build", + "typecheck", + "test", + "balance_verify", + "release_notes", + "smoke" + ], + "blessed": [ + "build", + "typecheck", + "test", + "balance_verify", + "release_notes" + ], + "gated": [ + "smoke" + ], + "prod_confirmation": [], + "notes": [ + "full blessed default stays local-first: build, typecheck, test, balance_verify, release_notes", + "full gated steps require explicit promotion: smoke", + "smoke: Depends on environment credentials or mutates a shared runtime surface." + ] + } + } + }, + "continuity": { + "session_file": "SESSION.md", + "handoff_pattern": "HANDOFF_.md", + "handoff_command": "pnpm exec temper handoff --cwd . --slug --summary \"\" --next \"\"", + "token_strategy": [ + "Read SESSION.md first for the active board.", + "Read the relevant HANDOFF_.md for restart detail.", + "Keep the session board short and push detail into handoffs." + ] + }, + "workflow_memory": { + "release_pattern": "beta environment is modeled; prod environment is modeled; 5 GitHub workflow files detected", + "continuity_pattern": "repo-native session tracking exists", + "recurring_failure_modes": [ + "Root `test` currently resolves to lint-style validation rather than a real test or verify path.", + "deep verification has shared-state steps that should stay promoted, not default: smoke" + ], + "recent_signals": [ + "2441 local commits available for pattern inference", + "recent commit mix chore:8, fix:4", + "top recommendations split-root-test-from-lint, gate-live-verification" + ] + }, + "resurfacing": [ + { + "id": "promote-gated-full-steps", + "phase": "ship", + "priority": "high", + "message": "Keep full-flow live verification explicit. Promote only when needed: smoke." + }, + { + "id": "root-test-is-lint", + "phase": "ship", + "priority": "high", + "message": "Do not read root `test` as real release confidence until the repo splits lint from verification." + }, + { + "id": "keep-workflow-context-current", + "phase": "session", + "priority": "medium", + "message": "Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in." + }, + { + "id": "watch-recurring-failure-modes", + "phase": "always", + "priority": "medium", + "message": "Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke" + } + ] +} diff --git a/.temper/assistants/shared-canon.md b/.temper/assistants/shared-canon.md new file mode 100644 index 000000000..6fd8df52d --- /dev/null +++ b/.temper/assistants/shared-canon.md @@ -0,0 +1,62 @@ +# Temper Shared Canon + +- Project: `ultimate-dominion` +- Root: `/Users/michaelorourke/ultimate-dominion` +- Family: `data-driven-progression-rpg` +- Stack: `browser-typescript-monorepo` +- Runtime: `pnpm exec temper` + +## Defaults +- Treat Temper as the operating layer for this repo. +- Fetch doctrine and routing before major design or release guidance. +- Use ship lite for narrow implementation confidence. +- Use ship full for player-facing, infra, economy, security, or multi-system work. + +## Capabilities +- `coach`: before designing a new system, feature, or data model — run this first, not after +- command: `pnpm exec temper coach --cwd . --json --intent ""` +- result: fetch doctrine and routing before you answer +- `ship_lite`: after the first working version of a feature is done — before moving to the next thing +- command: `pnpm exec temper ship lite --cwd . --intent ""` +- result: run the default low-risk ship path +- `ship_full`: before sharing with anyone, or when touching player-facing, economy, or multi-system code +- command: `pnpm exec temper ship full --cwd . --intent ""` +- result: run the deeper blessed ship path and surface any gated follow-ups +- `hotfix`: something is broken in a live environment and you need a recovery plan +- command: `pnpm exec temper hotfix --cwd . --json --env prod --intent ""` +- result: route the response through the hotfix doctrine surface +- `handoff`: ending a session, switching to a different workstream, or handing work to another agent +- command: `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` +- result: write a canonical restart artifact and update SESSION.md + +## Execution Policy +- stages: discovered -> recommended -> blessed -> gated +- promotion command: temper ship --promote +- lite discovered: build, test, release_notes +- lite blessed default: build, test, release_notes +- lite gated: none +- full discovered: build, typecheck, test, balance_verify, smoke, release_notes +- full blessed default: build, typecheck, test, balance_verify, release_notes +- full gated: smoke +- Live-stateful steps start gated and require explicit `--promote ` to run. +- Production-sensitive steps require both `--promote ` and `--confirm-prod`. + +## Continuity +- session file: SESSION.md +- handoff pattern: HANDOFF_.md +- handoff command: pnpm exec temper handoff --cwd . --slug --summary "" --next "" +- Read SESSION.md first for the active board. +- Read the relevant HANDOFF_.md for restart detail. +- Keep the session board short and push detail into handoffs. + +## Workflow Memory +- release pattern: beta environment is modeled; prod environment is modeled; 5 GitHub workflow files detected +- continuity pattern: repo-native session tracking exists +- recurring failure modes: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke +- recent signals: 2441 local commits available for pattern inference; recent commit mix chore:8, fix:4; top recommendations split-root-test-from-lint, gate-live-verification + +## Resurfacing +- Keep full-flow live verification explicit. Promote only when needed: smoke. +- Do not read root `test` as real release confidence until the repo splits lint from verification. +- Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in. +- Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke diff --git a/.temper/reports/adoption.md b/.temper/reports/adoption.md new file mode 100644 index 000000000..d9e55ab47 --- /dev/null +++ b/.temper/reports/adoption.md @@ -0,0 +1,43 @@ +# Temper Adoption Report + +- Project: ultimate-dominion +- Root: /Users/michaelorourke/ultimate-dominion +- Family: Data-Driven Progression RPG (data-driven-progression-rpg) +- Stack: Browser + TypeScript Monorepo (browser-typescript-monorepo) +- Package manager: pnpm + +## Source Of Truth Candidates +- packages/contracts/zones/dark_cave/effects.json +- packages/contracts/zones/dark_cave/items.json +- packages/contracts/zones/dark_cave/monsters.json +- packages/contracts/zones/windy_peaks/effects.json +- packages/contracts/zones/windy_peaks/items.json +- packages/contracts/zones/windy_peaks/monsters.json +- packages/contracts/worlds.json +- packages/contracts/mud.config.ts +- monsters.json +- CHANGELOG.md + +## Workflow Surfaces +- agents: AGENTS.md +- session: SESSION.md +- claude: CLAUDE.md +- claude_rules: .claude/rules/api.md, .claude/rules/client.md, .claude/rules/deploy.md, .claude/rules/game.md, .claude/rules/indexer.md, .claude/rules/solidity.md + +## Inferred Commands +- build: pnpm build (root:scripts.build) +- test: pnpm test (root:scripts.test) +- release_notes: pnpm changelog:dry (root:scripts.changelog:dry) +- typecheck: pnpm --filter client run typecheck (client:scripts.typecheck) +- smoke: pnpm --filter contracts run test:smoke:all (contracts:scripts.test:smoke:all) +- balance_verify: pnpm --filter contracts run test:balance (contracts:scripts.test:balance) + +## Recommended Ship Steps +- ship lite: build, test, release_notes +- ship full: build, typecheck, test, balance_verify, release_notes + +## Recommended Migration Stages +- 1. Install Temper config and assistant surfaces in read-only mode. +- 2. Use `temper ship lite --dry-run` against the repo until the inferred hooks feel right. +- 3. Tighten source-of-truth paths and environment branches inside temper.config.json. +- 4. Promote `ship full` and hotfix flows once the report matches lived workflow. diff --git a/.temper/reports/onboarding.json b/.temper/reports/onboarding.json new file mode 100644 index 000000000..2d7609724 --- /dev/null +++ b/.temper/reports/onboarding.json @@ -0,0 +1,477 @@ +{ + "generated_at": "2026-04-04T14:40:26.447Z", + "lifecycle": { + "id": "live", + "label": "Live Service / Existing Users Likely", + "reasons": [ + "A beta environment is explicitly modeled in repo workflows or deploy rules.", + "A production environment is explicitly modeled, which usually means operator mistakes have real user impact.", + "Both beta and prod exist alongside GitHub workflows, so release discipline already matters.", + "The repo has 2441 commits locally, which suggests an established system rather than a throwaway prototype.", + "The stack includes live-service overlays, so environment mistakes can mutate real state or real services." + ], + "operator_habit": "Treat default automation as a safety rail, not a shortcut. Beta and prod paths should stay explicit.", + "player_impact": "Small workflow mistakes can hit players directly through progression, deploy, or economy surfaces." + }, + "history": { + "commit_count": 2441, + "recent": [ + { + "hash": "c889a49e", + "date": "2026-04-04", + "subject": "chore: remove diagnostic code from indexer" + }, + { + "hash": "74293085", + "date": "2026-04-04", + "subject": "chore: force Docker rebuild with dynamic layer" + }, + { + "hash": "968e11f1", + "date": "2026-04-04", + "subject": "chore: add version to health endpoint for deploy verification" + }, + { + "hash": "1811bc93", + "date": "2026-04-04", + "subject": "chore: bump indexer to 0.2.4 — force Docker layer rebuild" + }, + { + "hash": "2f021946", + "date": "2026-04-04", + "subject": "chore: add snapshot debug endpoint to diagnose exclusion" + }, + { + "hash": "b045045e", + "date": "2026-04-04", + "subject": "chore: bust Docker cache for indexer snapshot exclusion" + }, + { + "hash": "a642be6f", + "date": "2026-04-04", + "subject": "chore: bump indexer to 0.2.3 for deploy cache bust" + }, + { + "hash": "cc5fc1fa", + "date": "2026-04-04", + "subject": "fix: reduce snapshot from 30MB to ~8MB — unblock game boot" + }, + { + "hash": "620385b3", + "date": "2026-04-03", + "subject": "fix: add forge clean to deploy scripts + dirty-tree guard for production" + }, + { + "hash": "864d7f5b", + "date": "2026-04-03", + "subject": "chore: regenerate Temper operating contract with voice rewrite" + }, + { + "hash": "cfef9e9a", + "date": "2026-04-03", + "subject": "fix: read Position via MUD store getRecord instead of broken UD__getEntityPosition view" + }, + { + "hash": "41a187fb", + "date": "2026-04-03", + "subject": "fix: prevent ghost-mob re-animation and concurrent validate bursts" + } + ], + "conventional_counts": { + "chore": 8, + "fix": 4 + } + }, + "workflows": { + "count": 5, + "files": [ + { + "path": ".github/workflows/ci.yml", + "triggers": [ + "push", + "pull_request" + ], + "environments": [] + }, + { + "path": ".github/workflows/deploy-beta.yml", + "triggers": [ + "workflow_dispatch" + ], + "environments": [ + "beta", + "mainnet" + ] + }, + { + "path": ".github/workflows/release.yml", + "triggers": [ + "push" + ], + "environments": [] + }, + { + "path": ".github/workflows/smoke.yml", + "triggers": [ + "push", + "pull_request" + ], + "environments": [ + "beta" + ] + }, + { + "path": ".github/workflows/sync-dev.yml", + "triggers": [ + "push", + "workflow_dispatch" + ], + "environments": [] + } + ] + }, + "strengths": [ + "The repo already has explicit operator context files for startup and session continuity.", + "Domain rules are already broken out into file-scoped guidance instead of one giant doc.", + "The project distinguishes local, beta, and prod instead of treating release as one flat surface.", + "Temper can detect canonical data and workflow surfaces without custom per-project code.", + "GitHub workflow files exist, so the repo has machine-readable release or validation paths.", + "The repo has enough local history for Temper to infer patterns instead of guessing from a shallow snapshot." + ], + "operator_answers": { + "name": "ultimate-dominion", + "family": "data-driven-progression-rpg", + "stack": "browser-typescript-monorepo", + "beta_branch": "dev", + "prod_branch": "main" + }, + "efficiency": { + "score": 70, + "current_startup_tokens": "~5K-15K tokens", + "projected_startup_tokens": "~2K-5K tokens", + "waste": [ + "No handoff docs detected. End-of-session context is more likely to get lost.", + "Root `test` is acting like lint. That creates false confidence and expensive backtracking.", + "Default `ship full` currently includes live-stateful verification. That is expensive, slower, and easier to misuse." + ], + "payoffs": [ + "Shared operating instructions exist, which lowers repeated orientation cost.", + "Session state is already explicit, which helps carry work across turns and agents.", + "Domain-specific rule files exist, so assistants can stay narrower and cheaper.", + "Canonical source-of-truth surfaces are detectable, which is one of the biggest token savers in long-lived repos." + ] + }, + "memory": { + "release_pattern": "beta environment is modeled; prod environment is modeled; 5 GitHub workflow files detected", + "continuity_pattern": "repo-native session tracking exists", + "recurring_failure_modes": [ + "Root `test` currently resolves to lint-style validation rather than a real test or verify path.", + "deep verification has shared-state steps that should stay promoted, not default: smoke" + ], + "recent_signals": [ + "2441 local commits available for pattern inference", + "recent commit mix chore:8, fix:4", + "top recommendations split-root-test-from-lint, gate-live-verification" + ] + }, + "resurfacing": [ + { + "id": "promote-gated-full-steps", + "phase": "ship", + "priority": "high", + "message": "Keep full-flow live verification explicit. Promote only when needed: smoke." + }, + { + "id": "root-test-is-lint", + "phase": "ship", + "priority": "high", + "message": "Do not read root `test` as real release confidence until the repo splits lint from verification." + }, + { + "id": "keep-workflow-context-current", + "phase": "session", + "priority": "medium", + "message": "Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in." + }, + { + "id": "watch-recurring-failure-modes", + "phase": "always", + "priority": "medium", + "message": "Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke" + } + ], + "script_audit": { + "root_scripts": { + "build": "pnpm recursive run build", + "build:client": "pnpm --filter 'client' run build", + "start:client": "pnpm --filter 'client' run preview", + "dev": "mprocs", + "dev:client": "pnpm --filter 'client' run dev", + "dev:contracts": "pnpm --filter 'contracts' dev", + "foundry:up": "curl -L https://foundry.paradigm.xyz | bash && bash $HOME/.foundry/bin/foundryup", + "foundry:install": "foundryup --install v0.3.0", + "mud:up": "pnpm mud set-version --tag main && pnpm install", + "format:client": "pnpm --filter 'client' run format", + "lint:client": "pnpm --filter 'client' run lint", + "test": "pnpm --filter client run lint", + "changelog": "node scripts/changelog.mjs", + "changelog:dry": "node scripts/changelog.mjs --dry-run" + }, + "issues": [ + { + "id": "root-test-is-lint", + "severity": "high", + "message": "Root `test` currently resolves to lint-style validation rather than a real test or verify path.", + "evidence": "pnpm --filter client run lint" + } + ] + }, + "execution_policy": { + "commands": { + "build": { + "id": "build", + "cmd": [ + "pnpm", + "build" + ], + "source": "root:scripts.build", + "script": "pnpm recursive run build", + "risk": "expensive_local", + "effect": "local", + "requires_confirmation": false, + "reasons": [ + "Runs local verification or compile work without explicit remote mutation." + ] + }, + "test": { + "id": "test", + "cmd": [ + "pnpm", + "test" + ], + "source": "root:scripts.test", + "script": "pnpm --filter client run lint", + "risk": "safe_local", + "effect": "local", + "requires_confirmation": false, + "reasons": [ + "Runs local verification or compile work without explicit remote mutation." + ] + }, + "release_notes": { + "id": "release_notes", + "cmd": [ + "pnpm", + "changelog:dry" + ], + "source": "root:scripts.changelog:dry", + "script": "node scripts/changelog.mjs --dry-run", + "risk": "safe_local", + "effect": "local", + "requires_confirmation": false, + "reasons": [ + "Generates local release metadata without mutating runtime state." + ] + }, + "typecheck": { + "id": "typecheck", + "cmd": [ + "pnpm", + "--filter", + "client", + "run", + "typecheck" + ], + "source": "client:scripts.typecheck", + "script": "tsc --noEmit", + "risk": "expensive_local", + "effect": "local", + "requires_confirmation": false, + "reasons": [ + "Runs local verification or compile work without explicit remote mutation." + ] + }, + "smoke": { + "id": "smoke", + "cmd": [ + "pnpm", + "--filter", + "contracts", + "run", + "test:smoke:all" + ], + "source": "contracts:scripts.test:smoke:all", + "script": "bash -c 'set -a && source .env.testnet && set +a && vitest run scripts/smoke-test/ --reporter=verbose --sequence.concurrent=false'", + "risk": "live_stateful", + "effect": "beta_or_target_env", + "requires_confirmation": true, + "reasons": [ + "Depends on environment credentials or mutates a shared runtime surface." + ] + }, + "balance_verify": { + "id": "balance_verify", + "cmd": [ + "pnpm", + "--filter", + "contracts", + "run", + "test:balance" + ], + "source": "contracts:scripts.test:balance", + "script": "vitest run scripts/balance/", + "risk": "expensive_local", + "effect": "local", + "requires_confirmation": false, + "reasons": [ + "Runs local verification or compile work without explicit remote mutation." + ] + } + }, + "hook_recommendations": { + "lite": { + "current_steps": [ + "build", + "test", + "release_notes" + ], + "recommended_default": [ + "build", + "test", + "release_notes" + ], + "gated_live": [], + "blocked_prod": [], + "notes": [] + }, + "full": { + "current_steps": [ + "build", + "typecheck", + "test", + "balance_verify", + "smoke", + "release_notes" + ], + "recommended_default": [ + "build", + "typecheck", + "test", + "balance_verify", + "release_notes" + ], + "gated_live": [ + "smoke" + ], + "blocked_prod": [], + "notes": [ + "Temper would keep full local-by-default and promote riskier steps only after explicit confirmation.", + "These full steps touch shared runtime state or require real environment credentials." + ] + } + }, + "lifecycle": { + "stages": [ + "discovered", + "recommended", + "blessed", + "gated" + ], + "promote_command": "temper ship --promote ", + "confirmation_rules": [ + "Live-stateful steps start gated and require explicit `--promote ` to run.", + "Production-sensitive steps require both `--promote ` and `--confirm-prod`." + ], + "ship_modes": { + "lite": { + "discovered_steps": [ + "build", + "test", + "release_notes" + ], + "recommended_steps": [ + "build", + "test", + "release_notes" + ], + "blessed_steps": [ + "build", + "test", + "release_notes" + ], + "gated_steps": [], + "prod_confirmation_steps": [], + "notes": [ + "lite blessed default stays local-first: build, test, release_notes" + ] + }, + "full": { + "discovered_steps": [ + "build", + "typecheck", + "test", + "balance_verify", + "smoke", + "release_notes" + ], + "recommended_steps": [ + "build", + "typecheck", + "test", + "balance_verify", + "release_notes", + "smoke" + ], + "blessed_steps": [ + "build", + "typecheck", + "test", + "balance_verify", + "release_notes" + ], + "gated_steps": [ + "smoke" + ], + "prod_confirmation_steps": [], + "notes": [ + "full blessed default stays local-first: build, typecheck, test, balance_verify, release_notes", + "full gated steps require explicit promotion: smoke", + "smoke: Depends on environment credentials or mutates a shared runtime surface." + ] + } + } + } + }, + "recommendations": [ + { + "id": "split-root-test-from-lint", + "priority": "high", + "title": "Split root lint from root verification", + "why": "Humans and assistants both read `test` as a confidence signal. If it only lints, the repo looks safer than it is.", + "operator_change": "Rename the root lint path or add a stronger root verify path that matches what `test` implies.", + "player_impact": "Fewer releases will be called validated when only style checks ran.", + "tradeoff": "One-time script and CI cleanup. Slightly more explicit command names afterward.", + "token_impact": "Saves rework tokens by reducing false-positive confidence during shipping." + }, + { + "id": "gate-live-verification", + "priority": "high", + "title": "Keep live-stateful verification out of the default `ship full` path", + "why": "Environment-bound smoke tests and sync-style commands are valuable, but they should be promoted deliberately, not inferred as routine local validation.", + "operator_change": "Use `ship full` for local/deep local confidence and run beta/live verification as an explicit next step.", + "player_impact": "Reduces accidental mutation of shared game state while keeping beta confidence available when it matters.", + "tradeoff": "One more explicit step when you want deep environment validation.", + "token_impact": "Cuts long, expensive runs from the default path and avoids recovery sessions after accidental live writes." + }, + { + "id": "codify-operator-context", + "priority": "medium", + "title": "Codify startup, session, and handoff surfaces", + "why": "Every missing operator doc pushes context back into chat, which is the most expensive place to store it.", + "operator_change": "Keep AGENTS, SESSION, and handoff docs short, current, and repo-native.", + "player_impact": "Less operator drift means fewer avoidable mistakes during hotfixes and live changes.", + "tradeoff": "Requires a few minutes of discipline at the start and end of a workstream.", + "token_impact": "This is one of the biggest recurring token savings because the same explanation stops repeating." + } + ] +} diff --git a/.temper/reports/onboarding.md b/.temper/reports/onboarding.md new file mode 100644 index 000000000..9d80bd134 --- /dev/null +++ b/.temper/reports/onboarding.md @@ -0,0 +1,110 @@ +# Temper Onboarding Report + +```text +()==========> T E M P E R + Heat. Hammer. Quench. Ship. +``` + +- Project: ultimate-dominion +- Root: /Users/michaelorourke/ultimate-dominion +- Family: Data-Driven Progression RPG (data-driven-progression-rpg) +- Stack: Browser + TypeScript Monorepo (browser-typescript-monorepo) +- Lifecycle inference: Live Service / Existing Users Likely +- Current startup token load: ~5K-15K tokens +- Projected after Temper contract + recommendations: ~2K-5K tokens + +## What Temper Sees +- environments: local:*, beta:dev, prod:main +- workflow surfaces: agents=AGENTS.md; session=SESSION.md; claude=CLAUDE.md; claude_rules=.claude/rules/api.md, .claude/rules/client.md, .claude/rules/deploy.md, .claude/rules/game.md, .claude/rules/indexer.md, .claude/rules/solidity.md +- source of truth: packages/contracts/zones/dark_cave/effects.json, packages/contracts/zones/dark_cave/items.json, packages/contracts/zones/dark_cave/monsters.json, packages/contracts/zones/windy_peaks/effects.json, packages/contracts/zones/windy_peaks/items.json, packages/contracts/zones/windy_peaks/monsters.json, packages/contracts/worlds.json, packages/contracts/mud.config.ts, monsters.json, CHANGELOG.md +- github workflows: .github/workflows/ci.yml, .github/workflows/deploy-beta.yml, .github/workflows/release.yml, .github/workflows/smoke.yml, .github/workflows/sync-dev.yml +- git history: 2441 commits; recent types chore:8, fix:4 + +## What Already Looks Good +- The repo already has explicit operator context files for startup and session continuity. +- Domain rules are already broken out into file-scoped guidance instead of one giant doc. +- The project distinguishes local, beta, and prod instead of treating release as one flat surface. +- Temper can detect canonical data and workflow surfaces without custom per-project code. +- GitHub workflow files exist, so the repo has machine-readable release or validation paths. +- The repo has enough local history for Temper to infer patterns instead of guessing from a shallow snapshot. + +## Lifecycle And Operator Posture +- A beta environment is explicitly modeled in repo workflows or deploy rules. +- A production environment is explicitly modeled, which usually means operator mistakes have real user impact. +- Both beta and prod exist alongside GitHub workflows, so release discipline already matters. +- The repo has 2441 commits locally, which suggests an established system rather than a throwaway prototype. +- The stack includes live-service overlays, so environment mistakes can mutate real state or real services. +- operator habit: Treat default automation as a safety rail, not a shortcut. Beta and prod paths should stay explicit. +- player impact: Small workflow mistakes can hit players directly through progression, deploy, or economy surfaces. + +## Workflow Memory +- release pattern: beta environment is modeled; prod environment is modeled; 5 GitHub workflow files detected +- continuity pattern: repo-native session tracking exists +- recurring failure mode: Root `test` currently resolves to lint-style validation rather than a real test or verify path. +- recurring failure mode: deep verification has shared-state steps that should stay promoted, not default: smoke +- recent signal: 2441 local commits available for pattern inference +- recent signal: recent commit mix chore:8, fix:4 +- recent signal: top recommendations split-root-test-from-lint, gate-live-verification + +## Token Efficiency +- score: 70/100 +- No handoff docs detected. End-of-session context is more likely to get lost. +- Root `test` is acting like lint. That creates false confidence and expensive backtracking. +- Default `ship full` currently includes live-stateful verification. That is expensive, slower, and easier to misuse. +- Shared operating instructions exist, which lowers repeated orientation cost. +- Session state is already explicit, which helps carry work across turns and agents. +- Domain-specific rule files exist, so assistants can stay narrower and cheaper. +- Canonical source-of-truth surfaces are detectable, which is one of the biggest token savers in long-lived repos. + +## Execution Policy +- safe local: test, release_notes +- expensive local: build, typecheck, balance_verify +- network readonly: none +- live stateful: smoke +- prod sensitive: none + +## Policy Lifecycle +- stages: discovered -> recommended -> blessed -> gated +- promotion command: temper ship --promote +- lite discovered: build, test, release_notes +- lite blessed default: build, test, release_notes +- lite gated: none +- full discovered: build, typecheck, test, balance_verify, smoke, release_notes +- full blessed default: build, typecheck, test, balance_verify, release_notes +- full gated: smoke +- Live-stateful steps start gated and require explicit `--promote ` to run. +- Production-sensitive steps require both `--promote ` and `--confirm-prod`. + +## Recommended Hook Shape +- ship lite current: build, test, release_notes +- ship lite recommended default: build, test, release_notes +- ship full current: build, typecheck, test, balance_verify, smoke, release_notes +- ship full recommended default: build, typecheck, test, balance_verify, release_notes +- promote to explicit beta/live verification: smoke +- keep behind explicit prod confirmation: none + +## Resurfacing +- [high] Keep full-flow live verification explicit. Promote only when needed: smoke. +- [high] Do not read root `test` as real release confidence until the repo splits lint from verification. +- [medium] Keep AGENTS, SESSION, and handoff surfaces current so restart cost does not creep back in. +- [medium] Recurring failure modes to keep visible: Root `test` currently resolves to lint-style validation rather than a real test or verify path.; deep verification has shared-state steps that should stay promoted, not default: smoke + +## Recommendations +- [high] Split root lint from root verification +- why: Humans and assistants both read `test` as a confidence signal. If it only lints, the repo looks safer than it is. +- operator change: Rename the root lint path or add a stronger root verify path that matches what `test` implies. +- player impact: Fewer releases will be called validated when only style checks ran. +- tradeoff: One-time script and CI cleanup. Slightly more explicit command names afterward. +- token impact: Saves rework tokens by reducing false-positive confidence during shipping. +- [high] Keep live-stateful verification out of the default `ship full` path +- why: Environment-bound smoke tests and sync-style commands are valuable, but they should be promoted deliberately, not inferred as routine local validation. +- operator change: Use `ship full` for local/deep local confidence and run beta/live verification as an explicit next step. +- player impact: Reduces accidental mutation of shared game state while keeping beta confidence available when it matters. +- tradeoff: One more explicit step when you want deep environment validation. +- token impact: Cuts long, expensive runs from the default path and avoids recovery sessions after accidental live writes. +- [medium] Codify startup, session, and handoff surfaces +- why: Every missing operator doc pushes context back into chat, which is the most expensive place to store it. +- operator change: Keep AGENTS, SESSION, and handoff docs short, current, and repo-native. +- player impact: Less operator drift means fewer avoidable mistakes during hotfixes and live changes. +- tradeoff: Requires a few minutes of discipline at the start and end of a workstream. +- token impact: This is one of the biggest recurring token savings because the same explanation stops repeating. diff --git a/.temper/workflow/HANDOFF_TEMPLATE.md b/.temper/workflow/HANDOFF_TEMPLATE.md new file mode 100644 index 000000000..a15629b1b --- /dev/null +++ b/.temper/workflow/HANDOFF_TEMPLATE.md @@ -0,0 +1,28 @@ +# Handoff — + +## Restart Point + +- branch: `` +- status: `` +- root: `` + +## What Changed + + + +## Next Steps + +1. +2. + +## Deploy / Environment State + +- local / beta / prod: + +## Notes + + + +Generated by Temper. Preferred command: + +- `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` diff --git a/.temper/workflow/continuity.json b/.temper/workflow/continuity.json new file mode 100644 index 000000000..8c80fa06f --- /dev/null +++ b/.temper/workflow/continuity.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "generated_at": "2026-04-04T14:40:26.655Z", + "project": { + "name": "ultimate-dominion", + "root": "/Users/michaelorourke/ultimate-dominion" + }, + "session_file": "SESSION.md", + "session_data_file": ".temper/workflow/session.json", + "handoff_pattern": "HANDOFF_.md", + "handoff_template_file": ".temper/workflow/HANDOFF_TEMPLATE.md", + "token_strategy": [ + "Read the Temper-managed session block in SESSION.md first.", + "Read the relevant HANDOFF_.md before relying on chat history.", + "Keep the session board short and point to handoff docs for detail." + ], + "handoff_command": "pnpm exec temper handoff --cwd . --slug --summary \"\" --next \"\"", + "current_branch": "main" +} diff --git a/.temper/workflow/session.json b/.temper/workflow/session.json new file mode 100644 index 000000000..2383f156f --- /dev/null +++ b/.temper/workflow/session.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "generated_at": "2026-04-04T14:40:26.681Z", + "entries": [ + { + "workstream": "main", + "branch": "main", + "status": "active", + "next": "Split root lint from root verification", + "handoff": "none", + "updated_at": "2026-04-04T14:40:26.681Z" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f08a39051 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,125 @@ +# Ultimate Dominion — Shared Agent Workflow + +This file is the shared workflow for Codex and Claude in this repo. Keep `CLAUDE.md` and `.claude/rules/` as the deep project reference; use this file for the common operating flow. + +## Startup Contract + +### Must Read + +1. `SESSION.md` in the main checkout root +2. `docs/direction/CURRENT.md` — auto-generated live working memory (focus, active threads, pivots, open questions). Regenerated every `/handoff`. Trust this over older memory files when they conflict. +3. `CLAUDE.md` +4. `git worktree list` +5. `git status -sb` + +If any required file or command output is missing, say so before starting implementation work. + +### Read On Demand + +- Relevant `.claude/rules/*.md` for the area being changed +- Relevant memory topics under `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/` +- Strategic docs under `~/Documents/ultimate-dominion/docs/` + +### Live State + +- `SESSION.md` in the main checkout root +- `HANDOFF_*.md` in the main checkout root +- Active worktrees and branch ownership +- Current deploy state: local, beta, prod + +If `SESSION.md` and the live worktree list disagree about what is active, flag that before starting implementation work. + +### Archive + +- `~/Documents/ultimate-dominion/docs/handoffs/` +- Older memory topics that are not relevant to the current task + +## Read Order + +1. `SESSION.md` in the main checkout root +2. `CLAUDE.md` +3. The relevant `.claude/rules/*.md` file for the files you will touch +4. The relevant project memory topics under `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/` + +## Session Start + +- Run `git worktree list` and `git status -sb`. +- Compare `SESSION.md` against the live worktree list and flag any mismatch. +- Confirm whether you are in the main checkout or a named worktree. +- If the task needs code changes, switch into or create the correct worktree first. +- Treat the main checkout as review/prod-ops context unless the task clearly belongs there. +- Load only the domain memory you need. Do not read the full memory tree. + +## Domain Rules + +Domain-specific rules live in `.claude/rules/`. Claude auto-loads these based on file patterns; **Codex must read the relevant file manually before working in that domain.** + +| Domain | Rules file | Read when touching... | +|--------|-----------|----------------------| +| Game systems, balance, items, combat | `.claude/rules/game.md` | `packages/contracts/src/systems/`, `items.json`, `effects.json`, `monsters.json` | +| Frontend, client UX | `.claude/rules/client.md` | `packages/client/**` | +| API, relayer | `.claude/rules/api.md` | `packages/api/**`, `packages/relayer/**` | +| Indexer, Railway | `.claude/rules/indexer.md` | `packages/indexer/**` | +| Solidity, MUD contracts | `.claude/rules/solidity.md` | `packages/contracts/**/*.sol`, `mud.config.*` | +| Deploys, env, infra | `.claude/rules/deploy.md` | deploy scripts, `.env*`, `worlds.json`, Railway/Vercel ops | + +## Core Rules + +- Every code change needs tests or the strongest relevant verification the repo supports, and the result must be reported honestly. +- `items.json`, `effects.json`, and domain docs remain source-of-truth surfaces. Follow the documented sync/verify flows. +- Active implementation work happens in a named worktree/workbranch, not in the main checkout. +- Workbranches/worktrees are durable session state. Never delete, prune, or remove one unless Michael explicitly asks. +- Explain work in plain English by default. Lead with player impact, product risk, and whether the change is only local, on beta, or on prod. +- Do not expect Michael to speak like an engineer. He is technical, but the reporting default is plain language. +- Local verification means the code builds successfully unless a task explicitly requires stronger local checks. +- Default shipping path for code changes: verify local build, deploy to beta on `dev`, then run the relevant integration tests there before calling the change ready. +- UD does not use local Anvil as the default validation path. Some legacy package scripts still mention `127.0.0.1:8545`; do not reach for those during normal work. Read `docs/operations/DEPLOY_RUNBOOK.md`, compile locally, then validate chain behavior on beta via fork/smoke/manual beta playtests. +- For beta deploys, follow `docs/operations/DEPLOY_RUNBOOK.md#beta-ci-path-standard`: compile locally, push to `dev`, run `deploy-beta.yml`, and treat a failure after `mud deploy` as “beta mutated but not validated” until the smoke gate passes. +- Commit after each logical unit with conventional commits. +- Do not sweep unrelated files into a commit. +- Do not push without approval. +- Keep mobile responsiveness in scope for frontend work. + +## Ask First + +- Any push to `main` +- Any production deploy +- Any `vercel --prod` equivalent or production Vercel action +- Railway production service deploys or env-var changes +- Security/access-control changes +- Spending money +- Deleting files, branches, records, or production resources +- Force push + +## Project Memory + +- Project memory root: `~/.claude/projects/-Users-michaelorourke-ultimate-dominion/memory/` +- Strategic docs: `~/Documents/ultimate-dominion/docs/` +- Handoff archive: `~/Documents/ultimate-dominion/docs/handoffs/` + +Start with `MEMORY.md`, then read only the relevant topic files: +- `memory/game/` for balance, systems, client sync, design, and game gotchas +- `memory/infra/` for deploys, Railway, relayer, recovery, and runbooks +- `feedback_*.md` for validated corrections +- `gotcha_*.md` for sharp edges and failure patterns + +## Handoff + +- Update `SESSION.md` before ending a workstream. +- Preferred handoff artifact: `~/Documents/ultimate-dominion/docs/handoffs/YYYY-MM-DD_[task-slug].md` +- If you create `HANDOFF_*.md` in repo root for compatibility with existing Claude tooling, archive it immediately after writing it. +- Include branch/worktree, commit hashes, decisions made, blockers, exact next steps, deploy state, and anything surprising. + + +## Temper + +Temper is installed as the operating layer for this repo. + +- Read `.temper/assistants/shared-canon.json` before major design or release guidance. +- Use `pnpm exec temper coach --cwd . --json --intent ""` before major design, balance, UX, infra, security, or release guidance. +- Use `pnpm exec temper ship lite --cwd . --intent ""` for narrow implementation confidence. +- Use `pnpm exec temper ship full --cwd . --intent ""` for player-facing, infra, economy, security, or multi-system work. +- Read `SESSION.md` first and use `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` when leaving a workstream. +- Treat `temper.config.json`, `.temper/assistants/shared-canon.json`, and `.temper/assistants/*.md` as the local Temper operating contract. +- Promote gated full steps explicitly with `pnpm exec temper ship full --cwd . --promote ` when you intend to run them. + diff --git a/CLAUDE.md b/CLAUDE.md index 4a830dc79..cfca73d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Ultimate Dominion - Project Memory +# Ultimate Dominion ## Game Manifesto (Design North Star) @@ -49,63 +49,17 @@ Full manifesto: `packages/client/src/pages/Manifesto.tsx`. Core principles: perm - Don't bundle unrelated work together. - If a change touches more than 3 systems, plan it first. -### Git Workflow -- Commit style: conventional commits (`feat:`, `fix:`, `docs:`, `chore:`, `refactor:`). -- Only commit what was worked on in the current session — don't sweep in unrelated uncommitted changes. -- Don't push without asking. - -### Autonomy Rules -**Do freely** (no confirmation needed): -- Read files, search the web, run tests, check health endpoints -- Deploy to beta (`dev` branch, testnet) -- Fix obvious bugs, apply config changes to beta -- Run forge scripts against testnet - -**Must ask first**: -- Push to `main` or deploy to production -- Spend money (any service, any amount) -- Delete files, branches, database records, or production resources -- Change env vars on Railway or Vercel -- Any security-sensitive change (keys, access control, permissions) -- Force push anywhere - ### Dependencies - Pin versions. Run `pnpm audit` before adding new packages. - Prefer well-maintained packages with small surface area. -### Status Updates -- Before any operation that takes more than 10 seconds, say what you're doing and roughly how long it'll take. - ### Definition of Done - Every task needs a verification command, commit hash, or live URL before it's closed. No closing on vibes. - -### Learn From Mistakes -- After any error that takes more than one attempt to fix, write the root cause and solution to `~/.claude/projects/-Users-michaelorourke/memory/learnings.md` (general) or `mud-gotchas.md` (MUD-specific) before moving on. - -### Session State Persistence -- Maintain `~/.claude/projects/-Users-michaelorourke/memory/SESSION.md` as a working scratchpad. -- **Update it** when: starting a new task, completing a task, hitting a blocker, making a commit, or any significant state change. -- **Read it first** at the start of every session to resume where we left off. - -## Tech Stack -- **Framework**: MUD (Lattice) v2 for on-chain game development -- **Contracts**: Solidity 0.8.24+, deployed via MUD World, tested with Forge (Foundry) -- **Frontend**: React 18, Chakra UI, Privy + RainbowKit, viem/wagmi -- **API**: Express on Vercel serverless, Pinata IPFS -- **Chain**: Base Mainnet (chain 8453) — both production and beta (separate world addresses) +- Local baseline: the build passes. +- Standard validation path: local build, beta deploy on `dev`, then the relevant integration tests in beta. ## Key Documentation -- `docs/INDEX.md` — **Start here.** Master hub linking all docs. -- Key refs: `GAME_DESIGN.md`, `ECONOMICS.md`, `COMBAT_SYSTEM.md`, `SYSTEM_ARCHITECTURE.md`, `APP_FLOW.md` -- Operations: `operations/launch_checklist.md`, `operations/DEPLOY_RUNBOOK.md`, `operations/ERROR_REFERENCE.md` -- Architecture: `architecture/` dir — TOKEN_GUIDE, ACCESS_CONTROL, AUTH_INTEGRATION, INDEXER, RELAYER, frontend_guidelines - -## Domain-Specific Rules -Loaded automatically via `.claude/rules/` when working in each domain: -- **`solidity.md`** — Security, access control patterns, MUD gotchas, gas safety, testing (activates on `packages/contracts/**/*.sol`) -- **`client.md`** — Performance, usability, crypto abstraction, SEO, player-facing copy (activates on `packages/client/**`) -- **`api.md`** — Rate limiting, CORS, input validation, no secret leakage (activates on `packages/api/**`, `packages/relayer/**`) -- **`deploy.md`** — Environment separation, branch conventions, MUD deploy safety, worlds.json (activates on deploy scripts, env files, mud.config) +- Start at `docs/INDEX.md` — master hub linking all docs. ## Reminders @@ -117,22 +71,16 @@ After every git commit, check `docs/operations/launch_checklist.md` for items th 2. No external security audit 3. Inconsistent access control on some admin functions -## Current Deploy State - -> **Canonical source for world addresses:** `packages/client/src/mud/worlds.json` (both envs). After any deploy, read this file — don't rely on hardcoded addresses elsewhere. - -| Env | Branch | Address Source | -|-----|--------|---------------| -| Production | `main` | `worlds.json` → chain `8453`, key `address` (production entry) | -| Beta | `dev` | `worlds.json` → chain `8453`, key `address` (beta entry) | + +## Temper -| Service | URL | -|---------|-----| -| Game (prod) | https://ultimatedominion.com | -| Game (beta) | https://beta.ultimatedominion.com | -| Guide | https://ud-guide.vercel.app | -| Tavern (forum) | https://tavern.ultimatedominion.com | -| Relayer | https://8453.relay.ultimatedominion.com | -| Indexer | https://indexer-us-production.up.railway.app | +Temper is installed as the operating layer for this repo. -**What's live for players:** Dark Cave (10x10 grid, levels 1-10), 3 races (Human/Elf/Dwarf), 9 advanced classes at level 10, turn-based PvE + PvP combat, NPC shop, player marketplace, escrow-based PvP economy, lore fragments, badges (Adventurer/Founder/Zone Conqueror), Tavern chat at level 3. Gas relayer abstracts all blockchain interaction. +- Read `.temper/assistants/shared-canon.json` before major design or release guidance. +- Use `pnpm exec temper coach --cwd . --json --intent ""` before major design, balance, UX, infra, security, or release guidance. +- Use `pnpm exec temper ship lite --cwd . --intent ""` for narrow implementation confidence. +- Use `pnpm exec temper ship full --cwd . --intent ""` for player-facing, infra, economy, security, or multi-system work. +- Read `SESSION.md` first and use `pnpm exec temper handoff --cwd . --slug --summary "" --next ""` when leaving a workstream. +- Treat `temper.config.json`, `.temper/assistants/shared-canon.json`, and `.temper/assistants/*.md` as the local Temper operating contract. +- Promote gated full steps explicitly with `pnpm exec temper ship full --cwd . --promote ` when you intend to run them. + diff --git a/HANDOFF_movement-monster-display.md b/HANDOFF_movement-monster-display.md new file mode 100644 index 000000000..4e966097e --- /dev/null +++ b/HANDOFF_movement-monster-display.md @@ -0,0 +1,84 @@ +# Handoff — Movement Monster Display / Beta Ghost Cleanup + +## Restart Point + +- workstream: `movement-monster-display` +- branch: `fix/movement-monster-display` +- head: `2986663f85a3` +- root: `/Users/michaelorourke/ultimate-dominion/.claude/worktrees/movement-monster-display` +- status: ready to resume +- deploy state: beta DB repaired; code pushed to `dev`; Railway beta indexer deploy pending after failed latest attempt + +## What Changed + +Movement monster display sync hardening is committed and pushed to `dev`. + +Follow-up beta indexer fix: +- `packages/indexer/src/api/snapshot.ts` now treats `PositionV2` rows at `(0,0)` as cleared/dead in the snapshot first pass. +- `packages/indexer/src/api/snapshot.test.ts` covers stale `Spawned=true` plus cleared `PositionV2`, ensuring dependent `Stats` are filtered. +- `packages/indexer/scripts/fix-ghost-monsters.ts` is now beta-only, dry-run by default, chain-verified, has no prod DB fallback, and uses project RPC env vars (`RPC_URL`, `RPC_HTTP_URL`, or `MONITOR_BASE_NODE_URL`) instead of public Base RPCs. +- `packages/indexer/package.json` bumped to `0.3.1` for Railway Docker cache busting. + +Commits: +- `4242f24f fix: harden movement monster display sync` +- `c8ac3ed2 docs: note movement monster sync hardening` +- `a5e1d931 fix: tighten monster display sync guards` +- `69415623 docs: note monster sync guard follow-up` +- `10db5e03 fix: preserve monster selector bigint hp typing` +- `f9ca4052 docs: note monster selector type fix` +- `783c9b50 fix: clear beta ghost monster snapshots` +- `2986663f chore: bump indexer for beta ghost fix` + +## Beta DB Repair + +Applied successfully to beta Postgres: +- World: `0xDc34AC3b06fa0ed899696A72B7706369864E5678` +- Service/env source: `indexer-beta-us` +- DB service: `postgres-beta-us` +- Result: `164/164` chain-verified ghost rows repaired at block `44660659` +- RPC read errors: `0` + +Important: the ghosts were not `Spawned=false` on-chain. The chain state was `Spawned=true` with `PositionV2=(0,0,0)`, so the client was correctly evicting cleared-position monsters and the beta indexer DB had stale nonzero `PositionV2` rows. + +## Verification + +Passed: +- `pnpm --filter @ud/indexer run build` +- `pnpm --filter @ud/indexer exec vitest run src/api/snapshot.test.ts` +- Earlier client movement sync targeted tests passed before this indexer follow-up. + +Known unrelated state: +- Full client CI remains blocked by pre-existing client typecheck/test failures outside this workstream. + +## Railway Deploy Status + +Not live yet. + +Current live beta indexer remains: +- deployment `78dc2765-bd40-490c-8c5d-986e22d2fe46` +- created `2026-04-09T13:29:45.724Z` +- builder `DOCKERFILE` +- rootDirectory `packages/indexer` + +Latest attempted deploy: +- deployment `a9ef5112-4742-4995-a2d7-14e9ec4b7827` +- status `FAILED` +- builder `RAILPACK` +- created `2026-04-13T20:17:36.829Z` + +What failed: +- `railway up --service indexer-beta-us --detach . --path-as-root` from the worktree uploaded the whole repo and hit `413 Payload Too Large`. +- Deploying `/tmp/ud-indexer-beta-deploy-2986663f/packages/indexer --path-as-root` uploaded a small context but Railway used `RAILPACK` instead of the existing Dockerfile/rootDirectory config and the deployment failed. + +## Next Steps + +1. Deploy commit `2986663f` to Railway service `indexer-beta-us` using the service's normal `packages/indexer` Dockerfile/rootDirectory config. +2. Verify `https://indexer-beta-us-production.up.railway.app/api/health`. +3. Fetch a fresh snapshot from `https://indexer-beta-us-production.up.railway.app/api/snapshot` and confirm cleared `PositionV2` rows are filtered. +4. Have Michael reload beta and confirm ghost creatures no longer appear/disappear while moving. + +## Notes + +Do not use public Base RPCs. Relevant memory: `feedback_rpc_ordering.md` says `rpc.ultimatedominion.com` / the project Base node is primary, Alchemy fallback only, no free public RPCs. The earlier repair dry-run with `https://mainnet.base.org` was aborted before running. The successful dry-run/apply used the `indexer-beta-us` Railway RPC env plus the `postgres-beta-us` public TCP proxy. + +The worktree is clean and `fix/movement-monster-display` equals `origin/dev` at `2986663f`. diff --git a/README.md b/README.md index c7dde9b86..68c4cc6c0 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The game runs at `http://localhost:3000`. Local development uses Anvil (chain 31 | Environment | Chain | URL | World Address | |-------------|-------|-----|---------------| | Local | Anvil (31337) | localhost:3000 | Deployed on `pnpm dev` | -| Beta | Base Mainnet (8453) | beta.ultimatedominion.com | `0x4a54538eCD32E1827121f9edb4a87CC4C08536E5` | +| Beta | Base Mainnet (8453) | beta.ultimatedominion.com | `0xDc34AC3b06fa0ed899696A72B7706369864E5678` | | Production | Base Mainnet (8453) | ultimatedominion.com | `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` | Both beta and production run on Base Mainnet, distinguished by world address. diff --git a/SESSION.md b/SESSION.md new file mode 100644 index 000000000..8520d59cf --- /dev/null +++ b/SESSION.md @@ -0,0 +1,13 @@ +# Active Work — Ultimate Dominion + +Updated: 2026-04-17 (WP quest HUD + dragon + fragment echo fixes shipped to beta; prereq gate deployed) + +## Pending +1. **SpellStats migration** — critical blocker. Add `ItemType.Spell` branch to `ItemCreationSystem.createItem()`, flip deploy script to `ITEM_TYPE_SPELL = 2`. Research complete. +2. Push `b13e4346` from prod-battle-results hotfix for prod. +3. Expand ASCII item icons to marketplace, tooltips, shop, trade UI. +4. Run BackfillZoneCompletions.s.sol for ALL characters on beta/prod. +5. **Onboarding spawn speed** — add fixed gas for `UD__spawn`, fire spawn during post-`enterGame` celebration. +6. **Production contract deploy window** — ghost mob fix + level cap + zone completion + spell stats all queued. +7. **Source missing battle SFX** (dodge, miss, take-damage) into `public/audio/sfx/battle/`. +8. **Railway indexer** — `2986663f` redeploy (413 failure still open). diff --git a/api/circulating-supply.ts b/api/circulating-supply.ts index d5723d045..b24c7760b 100644 --- a/api/circulating-supply.ts +++ b/api/circulating-supply.ts @@ -1,7 +1,7 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; const GOLD_TOKEN = "0x0F046E538926760A737761b555fe1074b6B1e16A"; -const RPC_URL = "https://base.drpc.org"; +const RPC_URL = process.env.RPC_HTTP_URL || "https://rpc.ultimatedominion.com"; // Team treasury wallet — excluded from circulating supply const EXCLUDED_WALLETS = [ diff --git a/api/total-supply.ts b/api/total-supply.ts index d1131e1be..bbb341478 100644 --- a/api/total-supply.ts +++ b/api/total-supply.ts @@ -1,7 +1,7 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; const GOLD_TOKEN = "0x0F046E538926760A737761b555fe1074b6B1e16A"; -const RPC_URL = "https://base.drpc.org"; +const RPC_URL = process.env.RPC_HTTP_URL || "https://rpc.ultimatedominion.com"; // ERC20 totalSupply() selector const TOTAL_SUPPLY_SELECTOR = "0x18160ddd"; diff --git a/docs/APP_FLOW.md b/docs/APP_FLOW.md index 2af463903..10413d44e 100644 --- a/docs/APP_FLOW.md +++ b/docs/APP_FLOW.md @@ -159,10 +159,24 @@ Split-screen: player inventory (left) vs shopkeeper inventory (right). - Buy: pay gold (with markup), receive items - Sell: trade items (with markdown), receive gold - Requires ERC20/ERC1155 allowance approval +- Repair equipment (`RepairShopPanel`) — available at any equipment shop. Z1 items currently skip repair (`maxDurability = 0`) until the Z1 durability rollout. - "Exit Shop" button returns to game board --- +## Respec (Stat Redistribution) + +**Route**: `/respec` + +- Accessed by interacting with **Vel Morrow**, the Combat Trainer in Windy Peaks (Z2, tile `2,3`). Not available on the character page. +- Two actions: + - **Redistribute Stats**: move existing STR/AGI/INT within the same total pool. Cost = `STAT_RESPEC_BASE_COST + level * RESPEC_COST_PER_LEVEL` (see `constants.sol`). + - **Full Reset**: resets class, level, and all equipment. Costs `FULL_RESPEC_MULTIPLIER ×` the stat respec cost. +- Blocked during combat (`CannotRespecInCombat` guard). +- Gated behind the `SHOW_Z2` feature flag. + +--- + ## Marketplace **Routes**: `/marketplace`, `/marketplace/items/:itemId` @@ -229,6 +243,7 @@ Split-screen: player inventory (left) vs shopkeeper inventory (right). | `/marketplace` | Yes | Yes | Yes | | `/marketplace/items/*` | Yes | Yes | Yes | | `/shops/:shopId` | Yes | Yes | Yes + active encounter | +| `/respec` | Yes | Yes | Yes (gated by `SHOW_Z2`) | --- diff --git a/docs/DURABILITY_SYSTEM.md b/docs/DURABILITY_SYSTEM.md index b494862da..ffe616caf 100644 --- a/docs/DURABILITY_SYSTEM.md +++ b/docs/DURABILITY_SYSTEM.md @@ -43,18 +43,18 @@ ItemDurability { ## Durability Values -Durability should scale with item rarity — rarer items last longer, giving players more time before needing repair. +Max durability scales with rarity — rarer items last longer per repair cycle. -| Rarity | Max Durability | Approx. Combats Before Broken | -|--------|---------------|-------------------------------| -| Common | 50 | ~50 | -| Uncommon | 75 | ~75 | -| Rare | 100 | ~100 | -| Very Rare | 150 | ~150 | -| Legendary | 200 | ~200 | -| Unique | 300 | ~300 | +| Rarity | Max Durability | Combats Before Broken | Full Repair Cost | +|--------|---------------|-----------------------|-----------------| +| R0 (Worn) | 20 | 20 | 1g | +| R1 (Common) | 30 | 30 | 7.5g | +| R2 (Uncommon) | 40 | 40 | 30g | +| R3 (Rare) | 50 | 50 | 75g | +| R4 (Epic) | 60 | 60 | 150g | -*These values assume 1 durability lost per combat. Actual loss rate TBD during testing.* +Durability loss: **1 per combat** (`DURABILITY_LOSS_PER_COMBAT = 1`). +Repair cost: **flat per-rarity rate per durability point** (not % of item price). --- @@ -85,67 +85,71 @@ Durability should scale with item rarity — rarer items last longer, giving pla ## Repair System ### Where -- Any NPC shop that sells equipment -- Future: dedicated Blacksmith NPC with better rates +- Any NPC shop that sells equipment — both **Tal** (Z1 Dark Cave, `9,9`) and **Tal Carden** (Z2 Windy Peaks, `9,9`). The `RepairShopPanel` is mounted unconditionally on `/shops/:shopId`. +- Future: dedicated Blacksmith NPC with better rates. +- **Current state**: `RepairShopPanel.tsx` skips items with `maxDurability === 0`. Z1 items have not had durability initialized on-chain yet, so the panel at Z1 Tal is currently inert for Z1 gear. When Z1 durability is activated, the same UI lights up automatically. ### Cost Formula ``` -repairCost = (item.price * durabilityLost * REPAIR_COST_PERCENT) / (maxDurability * 100) +repairCost = pointsToRepair * REPAIR_COST_PER_POINT[rarity] ``` -Where `REPAIR_COST_PERCENT` is a tunable constant (recommended: **10–20%** of item value for full repair). +Flat per-rarity cost per durability point (defined in `constants.sol`): -Example: A rare sword worth 500 gold, max durability 100, currently at 30: -- Durability lost: 70 -- At 15% rate: `(500 * 70 * 15) / (100 * 100)` = **52.5 gold** to fully repair +| Rarity | Cost Per Point | Full Repair Cost | +|--------|---------------|-----------------| +| R0 | 0.05g | 1g | +| R1 | 0.25g | 7.5g | +| R2 | 0.75g | 30g | +| R3 | 1.5g | 75g | +| R4 | 2.5g | 150g | ### Repair Behavior - Player can repair to full — no partial repair needed - Gold is **permanently burned** (not sent to shop) - Repair restores `currentDurability` to `maxDurability` -### Repair as Gold Sink — Projections +### Repair as Gold Sink — Projections (from chain data analysis, March 2026) -Assuming 20 concurrent players, avg level 9, 40 kills/hour: +**Z1 players** (40 fights/day, BASE_GOLD_DROP=2, income=202g/day): -| Scenario | Daily Repair Gold Burned | -|----------|------------------------| -| Conservative (players repair at 20%) | ~15,000 gold | -| Moderate (players repair at 40%) | ~25,000 gold | -| Aggressive (players let items break often) | ~35,000 gold | +| Gear | Daily Repair | % of Income | +|------|-------------|-------------| +| R1 weapon + R1 armor | 20g | 10% | +| R2 weapon + R2 armor | 60g | 30% | -This represents **6–15%** of daily gold generation (~240K/day), making it a meaningful but not punishing sink. +**Z2 players** (80 fights/day, income=1,204g/day): + +| Gear | Daily Repair | % of Income | +|------|-------------|-------------| +| R2 weapon + R2 armor | 120g | 10% | +| R3 weapon + R3 armor | 240g | 20% | +| R4 weapon + R4 armor | 400g | 25% | + +**At 200 DAU**: repair sink burns ~16,500g/day = 20% of total gold minting. Combined with death penalties, consumables, and marketplace fees: **38% total burn rate**. --- -## Implementation Plan - -### Phase 1: Table + Data (Can deploy now) -- [x] Add `ItemDurability` table to `mud.config.ts` -- [x] Run `tablegen` -- [ ] Deploy table to beta via `mud deploy --worldAddress` -- [ ] Write migration script: set `maxDurability` and `currentDurability` for all existing weapons, armor, and accessories based on rarity -- [ ] Update `ItemCreationSystem.createItem()` to set durability on new items - -### Phase 2: Durability Loss (Combat integration) -- [ ] Create `DurabilitySystem` (new system — **do NOT add to EncounterSystem**, it's at 24KB limit) -- [ ] `DurabilitySystem.deductDurability(characterId)`: reduces durability by 1 for all equipped weapon/armor/accessories -- [ ] Call `DurabilitySystem.deductDurability()` from `EncounterSystem` after combat resolves (single external call, minimal bytecode impact) -- [ ] Handle auto-unequip when item breaks mid-combat -- [ ] Grant `DurabilitySystem` access to Items namespace - -### Phase 3: Repair System -- [ ] Add `repairItem(uint256 itemId)` to `ShopSystem` or create dedicated `RepairSystem` -- [ ] Implement repair cost formula with tunable `REPAIR_COST_PERCENT` constant -- [ ] Gold is burned (not transferred to shop) -- [ ] Client UI: repair button in inventory, durability bar on items - -### Phase 4: Client UI -- [ ] Durability bar on item tooltips and inventory cards -- [ ] Color coding: green (>50%), yellow (25–50%), red (<25%), gray (broken) -- [ ] "Repair" button in shop interface -- [ ] Warning when equipping low-durability items -- [ ] Combat log message when an item breaks +## Implementation Status + +### Contracts — DONE +- [x] `ItemDurability` + `CharacterItemDurability` tables in `mud.config.ts` +- [x] `DurabilitySystem.sol` — degrade, repair, equip check, initialize, admin set +- [x] `EncounterResolveSystem.sol` calls `degradeEquippedItems()` (gas-guarded, line 76-78) +- [x] `EquipmentSystem.sol` checks `canEquipDurability()` (broken items can't be equipped) +- [x] Access grants in `EnsureAccess.s.sol` +- [x] Migration script: `DeployDurabilityData.s.sol` + +### Client — DONE +- [x] `DurabilityBar.tsx` — green/yellow/red progress bar on items +- [x] `RepairShopPanel.tsx` — full repair UI with cost calculation +- [x] `createSystemCalls.ts` — `repairItem()` system call +- [x] i18n strings in en/ko/ja/zh + +### Activation — PENDING +- [ ] Run `DeployDurabilityData.s.sol` for all item IDs (Z1 + Z2) on beta +- [ ] Run `DeployDurabilityData.s.sol` for all item IDs on production +- [ ] Verify on beta: fight → durability loss → repair at shop → gold burned --- @@ -199,12 +203,13 @@ This is fair to existing players — their items start at full durability, same ## Open Questions -- [ ] Exact `REPAIR_COST_PERCENT` — start at 10% or 15%? +- [x] ~~Repair cost model~~ — Resolved: flat per-rarity cost per point (not % of item price) - [ ] Should durability loss scale with mob level? (harder fights = more wear) - [ ] Should PvP have higher durability loss than PvE? (more risk = more sink) - [ ] Blacksmith NPC with repair discount as a future content addition? - [ ] Durability potions/buffs that slow degradation? (creates another consumable sink) +- [ ] Auto-repair toggle as QoL feature --- -*Last updated: March 5, 2026 — Initial design. Table schema deployed, implementation pending.* +*Last updated: March 30, 2026 — Full system implemented. Constants rebalanced based on chain data (20 days production). BASE_GOLD_DROP reduced 3→2. Z2 item prices raised ~3x. Durability active on all items (Z1+Z2).* diff --git a/docs/ECONOMICS.md b/docs/ECONOMICS.md index 431509527..747a92864 100644 --- a/docs/ECONOMICS.md +++ b/docs/ECONOMICS.md @@ -29,13 +29,13 @@ Gold drops from monster kills. Drop amounts scale with mob level via the formula | Mob Level | Gold Range | Average | |-----------|------------|---------| -| 1 | 0.05–3 | ~1.5 gold | -| 3 | 0.05–9 | ~4.5 gold | -| 5 | 0.05–15 | ~7.5 gold | -| 7 | 0.05–21 | ~10.5 gold | -| 10 | 0.05–30 | ~15.0 gold | +| 1 | 0.05–2 | ~1.1 gold | +| 3 | 0.05–6 | ~3.1 gold | +| 5 | 0.05–10 | ~5.1 gold | +| 7 | 0.05–14 | ~7.1 gold | +| 10 | 0.05–20 | ~10.1 gold | -**Current implementation**: `BASE_GOLD_DROP = 3` (on-chain constant). Scales with mob level. +**Current implementation**: `BASE_GOLD_DROP = 2` (on-chain constant, reduced from 3 in March 2026 economy rebalance). Scales with mob level. **Anti-farming**: If player is 5+ levels above mob, no gold drops. `[PLANNED]` — prevents high-level farming of easy content. diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index b2dd12841..d7b9b7692 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -393,7 +393,7 @@ Turn-based combat (same system as PvE) ## Lore Fragments -8 collectible narrative NFTs ("Fragments of the Fallen") that tell the story of Noctum's death and the gods' deicide. +8 collectible narrative NFTs ("Fragments") that tell the story of Noctum's death and the gods' deicide. | # | Fragment | Trigger | Status | |---|----------|---------|--------| @@ -401,7 +401,7 @@ Turn-based combat (same system as PvE) | 2 | The Quartermaster | Visit shop at (9,9) | `[IMPLEMENTED]` | | 3 | The Restless | First monster kill | `[IMPLEMENTED]` | | 4 | Souls That Linger | Kill Crystal Elemental (mob #4) | `[IMPLEMENTED]` | -| 5 | The Wound | Reach center tile (5,5) | `[IMPLEMENTED]` | +| 5 | The Marrow | Reach center tile (5,5) | `[IMPLEMENTED]` | | 6 | Death of Death God | Kill Lich Acolyte (mob #7) | `[IMPLEMENTED]` | | 7 | Betrayer's Truth | Kill Shadow Stalker (mob #9) | `[IMPLEMENTED]` | | 8 | Blood Price | First PvP kill | `[IMPLEMENTED]` | diff --git a/docs/LORE_BIBLE.md b/docs/LORE_BIBLE.md index f203140a7..37c53ab26 100644 --- a/docs/LORE_BIBLE.md +++ b/docs/LORE_BIBLE.md @@ -40,10 +40,10 @@ This question drives all content. It is never fully answered. Different eras exp The game explores faith without preaching either position: **Evidence FOR Faith:** -- Prayer demonstrably slows the world's decay +- Prayer demonstrably slows the Fraying - Temples feel different than other buildings - Some miracles happen (rarely, ambiguously) -- The faithful seem to resist void corruption better +- The faithful seem to resist Blind corruption better **Evidence AGAINST Faith:** - The gods are proven dead @@ -81,8 +81,8 @@ Does faith work because the gods aren't fully dead? Or does faith itself have po ### What Players Don't Know Yet -- They were thrown into the Wound as sacrifices, criminals, or heretics -- Their memories were taken by the Wound itself (death's domain is broken) +- They were thrown into the Marrow as sacrifices, criminals, or heretics +- Their memories were taken by the Marrow itself (death's domain is broken) - The cave is where a god died - They are not the first - and most don't survive @@ -102,36 +102,36 @@ What is known: - They did not die naturally - At least one was murdered by the others - Their deaths were not simultaneous -- Each death left a "Wound" in reality +- Each death left a "Marrow" in reality -### The Wounds +### The Marrows -Where a god died, reality is damaged. These are the Wounds - places where the rules break down, where the divine corpse still leaks power, where monsters spawn from corrupted essence. +Where a god died, reality is damaged. These are the Marrows - places where the rules break down, where the divine corpse still leaks power, where monsters spawn from corrupted essence. -The Dark Cave (starting zone) is the Wound of Noctum, god of death. This is why: +The Dark Cave (starting zone) is the Marrow of Noctum, god of death. This is why: - Players wake with no memory (death's domain is broken) - Souls linger as monsters instead of passing on - The dead don't stay dead properly -### The Decay +### The Fraying -Without gods maintaining reality, the world is slowly unraveling. This is called "the Decay." +Without gods maintaining reality, the world is slowly unraveling. This is called "the Fraying." -**Symptoms of Decay:** +**Symptoms of the Fraying:** - Time moves inconsistently in some places - The borders between life and death blur - Monsters spawn from nothing - People forget things that should be unforgettable -- The void presses in at the edges +- The Blind presses in at the edges **The Speed:** -The Decay is slow - generational. Fast enough to fear, slow enough to deny. This creates the religious tension: believers say faith slows it, cynics say it's natural entropy. +The Fraying is slow - generational. Fast enough to fear, slow enough to deny. This creates the religious tension: believers say faith slows it, cynics say it's natural entropy. --- ## The Pantheon of the Dead -Seven gods once existed. Each is dead. Each left a Wound. +Seven gods once existed. Each is dead. Each left a Marrow. ### Auros, the Radiant **Domain:** Sun, truth, judgment @@ -140,7 +140,7 @@ Seven gods once existed. Each is dead. Each left a Wound. **How They Died:** Murdered by the other six gods. Auros saw a truth that threatened them all and was silenced for it. -**What Remains:** The Eternal Eclipse - a zone where light doesn't function normally. His corpse still radiates, but the light reveals rather than illuminates. People see things they shouldn't. +**What Remains:** The Pyre - a zone where light doesn't function normally. His corpse still radiates, but the light reveals rather than illuminates. People see things they shouldn't. **Legacy:** The Covenant claims to follow Auros's light. They don't know he was killed by the gods they also honor. @@ -166,7 +166,7 @@ Seven gods once existed. Each is dead. Each left a Wound. **How They Died:** Fell in a battle against an enemy so terrible that reality itself forgot what it was. The enemy was erased - but so was the context of Korrath's victory. -**What Remains:** His armor, scattered across the world, still fights. Pieces possess those who wear them. The Battlefield Eternal is his Wound - ghosts still fighting a war no one remembers. +**What Remains:** His armor, scattered across the world, still fights. Pieces possess those who wear them. The Vigil is his Marrow - ghosts still fighting a war no one remembers. **Legacy:** The Iron General (Pretender God) wears pieces of Korrath's armor and may be partially possessed by the dead god's will. @@ -179,7 +179,7 @@ Seven gods once existed. Each is dead. Each left a Wound. **How They Died:** Drowned in her own domain. How does a sea goddess drown? Someone (something?) held her under. -**What Remains:** The seas are haunted. Her dreams leak into reality near coastlines. The Drowned Kingdom is her Wound - an underwater nightmare where her death replays eternally. +**What Remains:** The seas are haunted. Her dreams leak into reality near coastlines. The Undertow is her Marrow - an underwater nightmare where her death replays eternally. **Legacy:** The Tide Queen (Pretender God) emerged from Miren's corpse and may be her rage given form. @@ -192,7 +192,7 @@ Seven gods once existed. Each is dead. Each left a Wound. **How They Died:** Starved. When people stopped believing, stopped gathering, stopped building community - she weakened. The final blow came when her last temple was abandoned. -**What Remains:** Her temples still feel warm, but empty. The Empty Hearth is her Wound - abandoned cities where loneliness is a physical force that kills. +**What Remains:** Her temples still feel warm, but empty. The Silence is her Marrow - abandoned cities where loneliness is a physical force that kills. **Legacy:** Thessa's death is used as evidence by both sides. Believers say she died because faith died. Cynics say her death proves gods depend on mortals, not the reverse. @@ -205,9 +205,9 @@ Seven gods once existed. Each is dead. Each left a Wound. **How They Died:** The first to die. Murdered by Auros before the other deaths began. This broke the cycle of death - souls no longer transition properly. -**What Remains:** The Dark Cave is his Wound. Death doesn't work here. The dead linger. Memories fragment. The living wake with no past. +**What Remains:** The Dark Cave is his Marrow. Death doesn't work here. The dead linger. Memories fragment. The living wake with no past. -**Legacy:** Players begin in Noctum's Wound. Their amnesia, the undead monsters, the broken afterlife - all stem from his death. +**Legacy:** Players begin in Noctum's Marrow. Their amnesia, the undead monsters, the broken afterlife - all stem from his death. --- @@ -233,9 +233,9 @@ Seven gods once existed. Each is dead. Each left a Wound. ## The World State -### The Wound (Starting Area) +### The Marrow (Starting Area) -The cave system where players awaken. It is Noctum's Wound - where the god of death died. +The cave system where players awaken. It is Noctum's Marrow - where the god of death died. **Physical State:** - Dark (obviously), but darkness here has weight @@ -251,7 +251,7 @@ The cave system where players awaken. It is Noctum's Wound - where the god of de ### The Company -The organization that controls access to the Wound and maintains information control over the outside world. +The organization that controls access to the Marrow and maintains information control over the outside world. **Public Face:** A mining/expedition company extracting valuable resources from dangerous caves. @@ -265,7 +265,7 @@ The organization that controls access to the Wound and maintains information con **What They Know:** - The full truth about the dead gods - Something about the Seventh -- Why people are really thrown into the Wound +- Why people are really thrown into the Marrow --- @@ -277,7 +277,7 @@ The organization that controls access to the Wound and maintains information con **Core Belief:** The gods may be dead, but their light remains. Through faith, devotion, and preservation of the old ways, we hold back the darkness. Perhaps the gods can return - or we can become worthy of their legacy. **They're Right About:** -- Faith demonstrably slows the Decay +- Faith demonstrably slows the Fraying - Traditions provide stability in chaos - Without structure, society collapses @@ -345,7 +345,7 @@ The organization that controls access to the Wound and maintains information con | Issue | Covenant View | Unbound View | |-------|---------------|--------------| -| The Decay | Faith slows it | Faith is a bandage, not a cure | +| The Fraying | Faith slows it | Faith is a bandage, not a cure | | The gods | Honor their memory | Let them stay dead | | Order | Stability prevents chaos | Hierarchy enables tyranny | | Power | Earned through devotion | Earned through merit | @@ -353,14 +353,14 @@ The organization that controls access to the Wound and maintains information con **Crossover Potential:** - Characters can switch factions based on player influence - Leaders from opposing factions can show mutual respect -- Shared enemies (the Void) force temporary alliances +- Shared enemies (the Blind) force temporary alliances - Not all members match their faction's stereotype --- ## Core Characters - The Survivors -These characters wake in the Wound like the player - but before the player. They've survived longer. They're mentors, trainers, quest-givers. Players grow alongside them. +These characters wake in the Marrow like the player - but before the player. They've survived longer. They're mentors, trainers, quest-givers. Players grow alongside them. --- @@ -391,7 +391,7 @@ He wears practical leather armor, always slightly too big for him (scavenged), w - The emotional heart of the survivor group **Background:** -Baker's apprentice from a small town. Thrown into the Wound for a debt his father owed. Has been here six months when players arrive. Survived by being useful - organizing supplies, keeping morale up. +Baker's apprentice from a small town. Thrown into the Marrow for a debt his father owed. Has been here six months when players arrive. Survived by being useful - organizing supplies, keeping morale up. **Abilities He Teaches:** @@ -455,7 +455,7 @@ Her left ear is missing the top half - clean cut, a blade. She wears dark leathe - Guilt she won't name drives her **Background:** -Former Covenant Inquisition soldier. Trained killer. She followed orders until she saw what those orders meant - the families dragged away, the innocents burned. She deserted. Has been in the Wound for two years. The longest survivor except Senna. +Former Covenant Inquisition soldier. Trained killer. She followed orders until she saw what those orders meant - the families dragged away, the innocents burned. She deserted. Has been in the Marrow for two years. The longest survivor except Senna. **Abilities She Teaches:** @@ -526,7 +526,7 @@ He walks with a slight limp - broken ankle that healed wrong his first month. He - Stronger than he looks **Background:** -Lowborn acolyte who asked the wrong questions about church doctrine. Thrown into the Wound for heresy - specifically, for asking why the Inquisition burned innocents. Has been here eight months. Survives because he's too valuable to lose. +Lowborn acolyte who asked the wrong questions about church doctrine. Thrown into the Marrow for heresy - specifically, for asking why the Inquisition burned innocents. Has been here eight months. Survives because he's too valuable to lose. **Abilities He Teaches:** @@ -600,7 +600,7 @@ She wears dark practical clothes, layered, with many pockets. No visible weapons - Dark humor as survival **Background:** -This is her second time in the Wound. The first time, she escaped with four companions. They were going to change the world. Three died within a year. The fourth lived long enough to have a family - the Inquisition found them anyway. Senna came back voluntarily. She won't say why. +This is her second time in the Marrow. The first time, she escaped with four companions. They were going to change the world. Three died within a year. The fourth lived long enough to have a family - the Inquisition found them anyway. Senna came back voluntarily. She won't say why. **Abilities She Teaches:** @@ -666,7 +666,7 @@ These are the major figures players hear about and eventually meet. They're esta **Arc:** Mentor → Struggles against corruption → Terrible sacrifice OR lives to see faith validated/broken -**Potential Death Event:** "The Martyrdom of Evren" - Void attacks her temple, players defend +**Potential Death Event:** "The Martyrdom of Evren" - the Blind attacks her temple, players defend --- @@ -682,8 +682,8 @@ These are the major figures players hear about and eventually meet. They're esta **Personality:** - Publicly pious, privately monstrous - Knows gods are dead, doesn't care - power is power -- Responsible for throwing people into the Wound -- Has a reason: family killed by Void entities (doesn't excuse him) +- Responsible for throwing people into the Marrow +- Has a reason: family killed by Blind entities (doesn't excuse him) **Key Quote:** "Mercy is a luxury we cannot afford. The gods demand purity." @@ -828,7 +828,7 @@ Elderly, bald, serene. Paper-thin skin showing veins beneath. Moves with impossi **Physical Description:** [TO BE DEVELOPED] **What We Know:** -- Controls information about the Wound +- Controls information about the Marrow - Knows everything - Believes his cruelty is necessary - The Company predates both factions @@ -873,15 +873,15 @@ Content is structured in Eras - major shifts that change the world while maintai --- -### Era 3: The Void Answers +### Era 3: The Blind Answers **Theme:** What fills the absence -- Void entities emerge +- Blind entities emerge - Reality destabilizes -- Decay accelerates +- The Fraying accelerates - Things older than gods appear -**Endgame:** Push back the Void (temporarily) +**Endgame:** Push back the Blind (temporarily) --- @@ -891,7 +891,7 @@ Content is structured in Eras - major shifts that change the world while maintai **Factions:** - Resurrection Cult: Bring back old gods - Ascension Order: Elevate mortals -- Void Embrace: Become one with nothing +- Blind Embrace: Become one with nothing - Faithkeepers: Belief alone is enough **Endgame:** Faction warfare determines which philosophy wins @@ -900,7 +900,7 @@ Content is structured in Eras - major shifts that change the world while maintai ### Era 5+: The Cycle Continues Each era introduces: -- New void threats +- New Blind threats - New pretenders or ascension attempts - New revelations about dead gods - New zones @@ -913,7 +913,7 @@ The core questions remain unanswered. ## Zone Lore ### Dark Cave (Level 1-10) -**Wound of Noctum** +**Marrow of Noctum** **What Players Learn:** - Survival basics @@ -943,7 +943,7 @@ The core questions remain unanswered. **What's Hinted:** - Names of old gods -- References to "the Wound" +- References to "the Marrow" - Repeating symbols **Lore Fragments Available:** @@ -959,7 +959,7 @@ The core questions remain unanswered. **What Players Learn:** - What actually happened - Who the gods were -- What the Wound is +- What the Marrow is **What's Hinted:** - What's still coming @@ -1052,7 +1052,7 @@ NPCs reveal lore through: - Outcome inscribed: saved or died, who helped **"The Martyrdom of Evren" (Era 3 or 4)** -- Void attacks her temple +- The Blind attacks her temple - Covenant players defend - Her death or survival shapes next era @@ -1080,10 +1080,10 @@ These need to be resolved as the lore develops: - [ ] Specific quest chains for each core character - [ ] Item naming conventions and legendary items - [ ] How races work in this world (mostly human? other races?) -- [ ] The world outside the Wound - what's civilization like? +- [ ] The world outside the Marrow - what's civilization like? - [ ] Calendar system for inscribing dates - [ ] Pretender God full designs -- [ ] Void entity descriptions and motivations +- [ ] Blind entity descriptions and motivations --- @@ -1613,7 +1613,7 @@ Lira shows you that magic can be solid. Concentrated will made real. The quest i | Mentor | Lira | *Quest: "The God's Heartbeat"* -Velith, the Weaver, god of time - her corpse still beats. Once. Per day. In that moment, time stops everywhere except where she died. Lira believes wizards can tap into that moment, extend it, use it. This quest takes you to the edge of Velith's Wound. +Velith, the Weaver, god of time - her corpse still beats. Once. Per day. In that moment, time stops everywhere except where she died. Lira believes wizards can tap into that moment, extend it, use it. This quest takes you to the edge of Velith's Marrow. *Lira's Teaching:* "A god of time doesn't die like others. Her death takes forever. Literally. We're all living in her final heartbeat, stretched across eternity. If you listen carefully, you can hear when it pauses. And pause with it." @@ -1684,7 +1684,7 @@ Edric has gathered survivors who believe - in what, they're not sure. But when t | Mentor | Edric | *Quest: "The Unfinished Death"* -In Noctum's Wound, death doesn't work properly. Souls linger. Edric believes this is a curse - but curses can be leveraged. This quest explores the broken boundary between life and death, and whether pulling someone back is mercy or cruelty. +In Noctum's Marrow, death doesn't work properly. Souls linger. Edric believes this is a curse - but curses can be leveraged. This quest explores the broken boundary between life and death, and whether pulling someone back is mercy or cruelty. *Edric's Teaching:* "The god of death is dead. His rules don't apply anymore. Death is... negotiable now. I don't know if that's good or bad. But if someone died before their time, and I can bring them back? I will. Let theologians argue about it later." @@ -1759,7 +1759,7 @@ Lira explains that magical talent can build up like pressure in a pipe. Most peo | Mentors | Lira + Brother Aldous (joint) | *Quest: "The Convergence"* -The elements don't naturally work together - fire and ice oppose, lightning destabilizes both. But in the moment a god dies, the elements converge in chaos. Lira and Brother Aldous teach you to replicate that moment. The quest involves visiting multiple Wounds and attuning to each. +The elements don't naturally work together - fire and ice oppose, lightning destabilizes both. But in the moment a god dies, the elements converge in chaos. Lira and Brother Aldous teach you to replicate that moment. The quest involves visiting multiple Marrows and attuning to each. *Brother Aldous's Teaching:* "Balance doesn't mean peace. It means equal forces in opposition. A storm is perfectly balanced - it's just that the balance is violent." *Lira's Teaching:* "I've spent my life separating elements for study. You're going to put them back together. I hope you know what you're doing." diff --git a/docs/LORE_NFT_FRAGMENTS.md b/docs/LORE_NFT_FRAGMENTS.md index d93763aa1..ce676403e 100644 --- a/docs/LORE_NFT_FRAGMENTS.md +++ b/docs/LORE_NFT_FRAGMENTS.md @@ -1,4 +1,4 @@ -# Lore NFT Fragments: "Fragments of the Fallen" +# Lore NFT Fragments: "Fragments" This document details the collectible lore NFTs that reveal the story setup in the Dark Cave (Zone 1). @@ -12,7 +12,7 @@ This document details the collectible lore NFTs that reveal the story setup in t | 2 | The Quartermaster | Visit shop at (9,9) | — | | 3 | The Restless | First monster kill | — | | 4 | Souls That Linger | Kill Dark Wisp | 13 | -| 5 | The Wound | Reach tile (5,5) | — | +| 5 | The Marrow | Reach tile (5,5) | — | | 6 | Death of Death God | Kill Lich Acolyte | 25 | | 7 | Betrayer's Truth | Kill Void Whisper | 22 | | 8 | Blood Price | First PvP kill | — | @@ -21,7 +21,7 @@ This document details the collectible lore NFTs that reveal the story setup in t ## Overview -Players collect **8 lore fragments** through gameplay actions. Each fragment reveals part of the core narrative: the gods are dead, murdered, and the Dark Cave is the Wound left by Noctum's death. +Players collect **8 lore fragments** through gameplay actions. Each fragment reveals part of the core narrative: the gods are dead, murdered, and the Dark Cave is the Marrow left by Noctum's death. ### Progression @@ -29,7 +29,7 @@ Players collect **8 lore fragments** through gameplay actions. Each fragment rev Fragments 1-3: "Something is wrong here" | v -Fragments 4-5: "Death doesn't work / This is a Wound" +Fragments 4-5: "Death doesn't work / This is a Marrow" | v Fragments 6-7: "Gods were murdered / Noctum died first" @@ -128,7 +128,7 @@ Fragment 8: "You're becoming part of this place" --- -### Fragment V: "The Wound" +### Fragment V: "The Marrow" **Trigger:** Reach tile (5,5) - center of cave > *The air changes.* @@ -137,13 +137,13 @@ Fragment 8: "You're becoming part of this place" > > *The walls pulse. Not stone—something else. Something that remembers being alive.* > -> *You understand now. This cave isn't natural. It's a scar. A wound in the world itself, left by something's death. Something vast. Something that should not have been able to die.* +> *You understand now. This cave isn't natural. It's a scar. A marrow of the world itself, left by something's death. Something vast. Something that should not have been able to die.* > > *The old prayers call such places cursed. The scholars call them impossible.* > > *The survivors just call it what it is:* > -> *The Wound.* +> *The Marrow.* > > *And you are standing in its heart.* @@ -210,7 +210,7 @@ Fragment 8: "You're becoming part of this place" > > *You killed them.* > -> *In another place, this would mean something. Guards would come. Justice would follow. Here, in Noctum's Wound, there is no justice. No law. Only survival.* +> *In another place, this would mean something. Guards would come. Justice would follow. Here, in Noctum's Marrow, there is no justice. No law. Only survival.* > > *You wait for guilt. For horror. For the weight of what you've done.* > @@ -236,7 +236,7 @@ Fragment 8: "You're becoming part of this place" | 2 | The Quartermaster | Talk to Tal | Others have died here, you're not alone | | 3 | The Restless | First monster kill | Creatures are compelled, not willing | | 4 | Souls That Linger | Kill Dark Wisp | Monsters are trapped human souls | -| 5 | The Wound | Reach center (5,5) | This place is a divine death-scar | +| 5 | The Marrow | Reach center (5,5) | This place is a divine death-scar | | 6 | Death of the Death God | Kill Lich Acolyte | Noctum (death god) was murdered, death is broken | | 7 | The Betrayer's Truth | Kill Void Whisper | Auros killed Noctum, then was killed for a secret truth | | 8 | Blood Price | First PvP kill | You're capable of the same violence that made this place | @@ -245,24 +245,24 @@ Fragment 8: "You're becoming part of this place" ## UI/UX Design -### Core Concept: Memory Echoes +### Core Concept: Impressions -When a trigger condition is met, a **Memory Echo** appears on the player's current tile. These are fragments of memory pressed into reality by the Wound, waiting to be absorbed. The echo appears as a glowing, ethereal wisp that pulses with soft light. +When a trigger condition is met, an **Impression** appears on the player's current tile. These are fragments of memory pressed into reality by the Marrow, waiting to be absorbed. The Impression appears as a glowing, ethereal wisp that pulses with soft light. ### Interaction Flow ``` 1. Player is on tile (e.g., 5,5) 2. Trigger happens (reach tile, kill monster, talk to NPC) -3. Echo appears on THAT SAME TILE (player is already there) -4. Player clicks Echo → Modal opens +3. Impression appears on THAT SAME TILE (player is already there) +4. Player clicks Impression → Modal opens 5. Player claims → NFT minted, modal closes -6. Echo disappears permanently (never shows again for this player) +6. Impression disappears permanently (never shows again for this player) ``` ### Tile Placement Per Trigger -| Trigger | Echo Appears On | When | +| Trigger | Impression Appears On | When | |---------|-----------------|------| | First spawn | Tile (0,0) - spawn point | Immediately on first game load | | Talk to Tal | Tile (9,9) - Tal's tile | After closing shop UI | @@ -284,12 +284,12 @@ BEFORE TRIGGER: AFTER TRIGGER: │ (5,5) │ │ (5,5) │ └─────────────────┘ └─────────────────┘ -Player and Echo both on same tile - Echo is clickable +Player and Impression both on same tile - Impression is clickable ``` ### Board Interaction -When echo appears on player's tile: +When Impression appears on player's tile: ``` ┌─────────────────────────────────────────────────────────────┐ @@ -297,10 +297,10 @@ When echo appears on player's tile: │ [ Game Board ] │ │ │ │ Player standing on tile │ -│ Echo visible on same tile │ +│ Impression visible on same tile │ │ │ │ ┌─────────────────────────────────────┐ │ -│ │ ✦ Memory Echo [Click to view] │ ← Clickable │ +│ │ ✦ Impression [Click to view] │ ← Clickable │ │ └─────────────────────────────────────┘ overlay/button │ │ │ └─────────────────────────────────────────────────────────────┘ @@ -308,7 +308,7 @@ When echo appears on player's tile: ### Claim Modal -When player clicks the echo: +When player clicks the Impression: ``` ┌─────────────────────────────────────────────────────────────┐ @@ -319,7 +319,7 @@ When player clicks the echo: │ ╚═══════════════════╝ │ │ │ │ ─── Fragment V of VIII ─── │ -│ « THE WOUND » │ +│ « THE MARROW » │ │ │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ The air changes. │ │ @@ -336,13 +336,13 @@ When player clicks the echo: └─────────────────────────────────────────────────────────────┘ ``` -After claim → Modal closes, echo gone forever. +After claim → Modal closes, Impression gone forever. --- ### Character Page: Fragment Collection -Add a "Fragments of the Fallen" section to the character page: +Add a "Fragments" section to the character page: ``` ┌─────────────────────────────────────────────────────────────┐ @@ -360,7 +360,7 @@ Add a "Fragments of the Fallen" section to the character page: │ └────────┘ └────────┘ └────────┘ │ │ │ │─────────────────────────────────────────────────────────────│ -│ FRAGMENTS OF THE FALLEN 5/8 │ +│ FRAGMENTS 5/8 │ │ │ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │ ✦I │ │ ✦II │ │✦III │ │ ✦IV │ │ ✦V │ │ ?VI │ │?VII │ │?VIII│ @@ -427,7 +427,7 @@ Clicking a claimed fragment on the character page: | II - The Quartermaster | "Seek the one who counts the living..." | | III - The Restless | "Even the smallest creature holds a secret..." | | IV - Souls That Linger | "Seek the souls that glow with unnatural light..." | -| V - The Wound | "The heart of the cave holds secrets..." | +| V - The Marrow | "The heart of the cave holds secrets..." | | VI - Death of the Death God | "Those who served death in life still pray..." | | VII - The Betrayer's Truth | "The void remembers what others forget..." | | VIII - Blood Price | "Some knowledge comes only through blood..." | @@ -437,8 +437,8 @@ Clicking a claimed fragment on the character page: | State | On Board | On Character Page | |-------|----------|-------------------| | **Not triggered yet** | Nothing shown | `?` with hint on hover | -| **Triggered, not claimed** | Echo visible on tile | `?` (still undiscovered until claimed) | -| **Claimed** | Echo never appears again | `✦` clickable to re-read | +| **Triggered, not claimed** | Impression visible on tile | `?` (still undiscovered until claimed) | +| **Claimed** | Impression never appears again | `✦` clickable to re-read | --- @@ -457,10 +457,411 @@ Each zone will have its own set of lore fragments revealing more about the dead --- +--- + +# Zone 2: Windy Peaks — Fragment Chains (IX-XVI) + +## Overview + +Zone 2 upgrades from Z1's simple triggers to **multi-step chained quests**. Each fragment requires 2-4 sequential actions using varied verbs (kill, explore, talk, find, survive, examine). Players pursue truth through action rather than stumbling into it. + +**8 fragments** organized into **3 narrative chains**: + +``` +PEAKS CHAIN ──── Fragment IX: "The Ascent" (arrival, 1 step) + │ + └──────────── Fragment XVI: "The Wind's Memory" (capstone, 3 steps, requires 4+ Z2 frags) + +VEL CHAIN ────── Fragment X: "Vel's Warning" (2 steps) + │ │ + │ Fragment XI: "The Orders" (2 steps) + │ │ + │ Fragment XII: "What She Left Behind" (3 steps) + +EDRIC CHAIN ──── Fragment XIII: "The Shrine" (3 steps) + │ │ + │ Fragment XIV: "The Heretic's Question" (2 steps) + │ │ + │ Fragment XV: "Bones of Faith" (3 steps) +``` + +### Trigger Summary + +| # | Fragment | Chain | Steps | Trigger Sequence | +|---|----------|-------|-------|------------------| +| IX | The Ascent | Peaks | 1 | TileVisit (zone spawn) | +| X | Vel's Warning | Vel | 2 | NpcInteract (Vel) → CombatKill (Covenant Scout) | +| XI | The Orders | Vel | 2 | CombatKill (Covenant Tracker) → NpcInteract (Vel) | +| XII | What She Left Behind | Vel | 3 | TileVisit (camp) → NpcInteract (camp journal) → NpcInteract (Vel) | +| XIII | The Shrine | Edric | 3 | TileVisit (shrine) → CombatKill (Fraying Guardian) → NpcInteract (shrine inscriptions) | +| XIV | The Heretic's Question | Edric | 2 | NpcInteract (Edric) → NpcInteract (Edric at shrine) | +| XV | Bones of Faith | Edric | 3 | TileVisit (ossuary) → CombatKill (Ossuary Guardian) → NpcInteract (Edric) | +| XVI | The Wind's Memory | Peaks | 3 | TileVisit (summit) → CombatKill (Gale Fury) → NpcInteract (summit stone) | + +### Quest Items + +| Item | Drops From | Fragment | Step | Type | +|------|-----------|----------|------|------| +| Sealed Letter | Covenant Tracker kill | XI | 0 | QuestItem (permanent memento) | +| Last Sermon | Ossuary Guardian kill | XV | 1 | QuestItem (permanent memento) | + +--- + +## PEAKS CHAIN + +### Fragment IX: "The Ascent" +**Chain steps:** 1 (simple trigger — bridge from Z1's system) +**Trigger:** TileVisit — arrive at Windy Peaks spawn point (auto-completes on zone entry) + +> *Light.* +> +> *It hits you like a fist. After weeks — months? — in Noctum's Marrow, your eyes have forgotten what light is. You stagger, blinded, hands up against something you once took for granted.* +> +> *Then the wind.* +> +> *It screams across the ridge, tearing at your clothes, your hair, the parts of you that were starting to feel safe. The cave was a prison. This is a precipice.* +> +> *You force your eyes open.* +> +> *The sky stretches forever — but it's wrong. At the edges, colors bleed into each other like wet paint, and the horizon shimmers in a way that makes your stomach lurch. The Fraying. You can see it from here. The world's edges are coming undone.* +> +> *Below you, the peaks descend in jagged steps — ancient stairs carved for something larger than humans. Ruins cling to the cliff faces like barnacles, their windows dark, their doors long gone. Someone built here, once. Someone who isn't here anymore.* +> +> *The wind shifts. For a moment, just a moment, it sounds like a name.* +> +> *Yours?* +> +> *No. Someone else's. Someone who stood here before you.* + +**Purpose:** Transition from Z1's claustrophobia to Z2's exposure. Establishes the Fraying as visible, the ruins as ancient, and the wind as almost alive. Celebration moment for surviving the Dark Cave. + +--- + +### Fragment XVI: "The Wind's Memory" +**Prerequisite:** 4+ other Z2 fragments claimed +**Chain steps:** 3 + +1. **TileVisit:** Reach the Summit tile (highest point on the map) +2. **CombatKill:** Survive the Gale Fury (environmental combat mob, level 18) +3. **NpcInteract:** Examine the Summit Stone (world object) + +> *The stone is ancient. Older than the ruins. Older than the stairs.* +> +> *Names. Thousands of them, carved in hands steady and shaking, in scripts you recognize and scripts that died with their speakers. This was a pilgrimage site. People climbed here to leave their mark before descending into the Marrow below. Willingly.* +> +> *Your eyes scan the stone. So many names. So many who came before.* +> +> *Then you see it.* +> +> *Near the bottom. Fresh, compared to the others. The carving is confident, precise — someone who knew how to hold a chisel. And the handwriting...* +> +> *It's yours.* +> +> *You don't remember carving it. You don't remember climbing here. You don't remember choosing to descend into Noctum's grave. But your hand made this mark. You came here on purpose.* +> +> *You weren't thrown away.* +> +> *You walked in.* +> +> *Why?* + +**Purpose:** Capstone revelation for Z2. Completely reframes the player's origin story. Fragment I said "you were thrown away." Fragment XVI says: no, you chose this. The mystery deepens — why would anyone voluntarily enter a dead god's Marrow? + +--- + +## VEL CHAIN + +### Fragment X: "Vel's Warning" +**Prerequisite:** Fragment IX claimed +**Chain steps:** 2 + +1. **NpcInteract:** Talk to Vel (at her ridge position) +2. **CombatKill:** Kill a Covenant Scout (level 13, spawns near Vel's position) + +> *Vel doesn't look at the body.* +> +> *"He's the first. He won't be the last." She cleans her blade on the dead man's cloak with the practiced efficiency of someone who's done it a thousand times. "The Inquisition doesn't send one. They send one to confirm, then they send the rest."* +> +> *She finally looks at you. Really looks. For the first time, you see something behind the ice.* +> +> *Fear.* +> +> *Not of the Covenant. Not of fighting. Something older.* +> +> *"I served them. For twelve years. I was their Third Blade — you don't know what that means and you don't want to. When I left, I didn't just desert. I took something. Proof of what they did. What they're still doing."* +> +> *She looks back at the paths below.* +> +> *"They're not here for justice. They're here to make sure the proof dies with me."* + +**Purpose:** Vel's ice cracks. The Covenant is real, organized, and present. Establishes the chain's stakes: Vel has evidence of Covenant atrocities. + +--- + +### Fragment XI: "The Orders" +**Prerequisite:** Fragment X claimed +**Chain steps:** 2 + +1. **CombatKill:** Kill a Covenant Tracker (level 15, drops **Sealed Letter** quest item) +2. **NpcInteract:** Bring the Sealed Letter to Vel + +> *She breaks the seal without hesitation. Her eyes move across the words. Her face doesn't change — but her hands do. The left one tightens on the letter. The right one drops to her sword.* +> +> *"Seraph Morrow," she says. Her real name, spoken like a curse. "Covenant Inquisition, Third Blade, Auros Division. Wanted for: desertion, theft of sealed records, murder of Inquisitor Dalhan..."* +> +> *She pauses.* +> +> *"...and the unauthorized release of classified intelligence regarding the Cleansing of Thornfield."* +> +> *She looks up. Her eyes are dry. Her voice is not.* +> +> *"Thornfield was a village. Three hundred people. The Covenant said they were harboring heretics — people who claimed the gods were dead. The Inquisition sent us to 'cleanse' the heresy." She folds the letter, precisely, along the creases. "There were no heretics. There were farmers. And children. And I followed orders."* +> +> *"The proof I took? It's the kill roster. Every name. Every age. Every 'heretic' we murdered. The youngest was four."* +> +> *She puts the letter in her belt.* +> +> *"Terminate with prejudice. Signed by Commander Lias Coryn." A ghost of something crosses her face. "He taught me to fight. He was the closest thing I had to a father."* + +**Purpose:** The gut punch. Vel's real name, her real crime (she stole evidence of a massacre), and the personal betrayal (her mentor signed her death warrant). + +--- + +### Fragment XII: "What She Left Behind" +**Prerequisite:** Fragment XI claimed +**Chain steps:** 3 + +1. **TileVisit:** Reach the abandoned Covenant camp tile +2. **NpcInteract:** Examine the camp journal (world object) +3. **NpcInteract:** Talk to Vel (at her new position) + +> *The journal belonged to one of her squad. Someone who stayed. The entries are clinical — supply counts, patrol routes, target descriptions. But near the end, the handwriting changes. Smaller. Shakier.* +> +> *"Seraph was the best of us. She made it look easy. I watched her walk away and I envied her. I still had the stomach for it. I wish I didn't."* +> +> *You bring the journal to Vel. She reads the entry. Then reads it again.* +> +> *For a long time, she says nothing. The wind fills the silence.* +> +> *"I thought about going back. Not to the Covenant — to Thornfield. To stand in the ashes and... I don't know. Apologize to ghosts." Her jaw tightens. "But ghosts don't need apologies. The living do."* +> +> *She hands the journal back to you.* +> +> *"Keep it. Someone should remember what they did. If the proof dies with me, keep the journal. Tell people what happened at Thornfield. Tell them it was real."* +> +> *She turns toward the wind.* +> +> *"I can't undo it. But I can make sure it doesn't end quietly."* + +**Purpose:** Vel moves from running to standing. The journal humanizes her former squad. The Covenant's evil is institutional, not cartoon. + +--- + +## EDRIC CHAIN + +### Fragment XIII: "The Shrine" +**Prerequisite:** Fragment IX claimed +**Chain steps:** 3 + +1. **TileVisit:** Discover the ruined shrine tile +2. **CombatKill:** Kill the Fraying-touched Guardian (level 16) +3. **NpcInteract:** Examine the shrine inscriptions (world object) + +> *The shrine is to Korrath. God of war. His symbol — a sword through a shield — is carved above the entrance, cracked but legible.* +> +> *But the prayers carved into the walls aren't what you expected.* +> +> *"Korrath, Lord of Duty, grant us the wisdom to put down our swords."* +> +> *"Korrath, Keeper of Sacrifice, let this be the last war."* +> +> *"Korrath, we are tired. Let it end."* +> +> *The god of war's worshippers prayed for peace. Not victory. Not glory. Peace.* +> +> *On the altar, scratched in frantic letters, a final message: "He heard us. He put down his sword. And they killed him for it."* +> +> *Korrath didn't fall in battle. He chose to stop fighting. And the other gods couldn't allow that.* +> +> *A god of war who chose peace.* +> +> *They murdered him for it.* + +**Purpose:** Reframes a god's death. Korrath is tragic — he tried to change and was killed for it. Complicates the "gods were murdered" narrative from Z1. + +--- + +### Fragment XIV: "The Heretic's Question" +**Prerequisite:** Fragment XIII claimed +**Chain steps:** 2 + +1. **NpcInteract:** Talk to Edric (he agrees to go to the shrine) +2. **NpcInteract:** Meet Edric at the shrine (he appears there, prayer triggers) + +> *Edric kneels at the altar. You expect the usual — the rote Covenant prayers, the formulaic devotions. Instead, he's quiet for a long time.* +> +> *Then:* +> +> *"I don't know who I'm talking to. I used to. I used to know exactly who heard me and I used to believe they cared. Now I know they're dead and I should stop."* +> +> *His voice is steady. His hands are not.* +> +> *"But I can't. Because something answered. Sometimes. In the Marrow, when I prayed over the dying, their pain eased. Not always. But sometimes. If the gods are corpses, what eased their pain? If nothing hears prayer, why did it work?"* +> +> *He presses his forehead to the stone.* +> +> *"Please. I'm not asking for a miracle. I'm asking for honesty. Is anyone there?"* +> +> *Silence.* +> +> *Then — the shrine warms. Not visibly, not dramatically. No golden light. No voice from heaven. Just... warmth. Like a hand on a shoulder. Like being remembered.* +> +> *Edric's eyes open. He doesn't smile. He doesn't cry.* +> +> *"That's not an answer," he whispers.* +> +> *"But it's not nothing."* + +**Purpose:** The emotional core of Z2. Something happened. Was it divine? Residual god-energy? The Fraying? The game never says. + +--- + +### Fragment XV: "Bones of Faith" +**Prerequisite:** Fragment XIV claimed +**Chain steps:** 3 + +1. **TileVisit:** Discover the Ossuary tile +2. **CombatKill:** Kill the Ossuary Guardian (level 17, drops **Last Sermon** quest item) +3. **NpcInteract:** Bring the Last Sermon to Edric + +> *Edric reads the tablet slowly. His lips move. His eyes widen.* +> +> *"Brother Aldain. He was the last keeper of this shrine. He wrote this knowing no one might ever read it."* +> +> *He translates aloud:* +> +> *"The gods are dying. I have seen the proof. Korrath fell here, in this place, and his Marrow spreads through the stone beneath our feet. I should despair. Every teaching says I should. The foundations of my faith are corpses."* +> +> *Edric pauses. Swallows.* +> +> *"But I have seen something the teachings did not prepare me for. The gods can die. They did die. And yet — the world continues. Broken, yes. Fraying, yes. But continuing. If divinity is not eternal, then divinity is not what we were told. And if mortals outlive gods..."* +> +> *His voice cracks.* +> +> *"...then perhaps we were always the miracle."* +> +> *He sets the tablet down carefully. His hands have stopped shaking.* +> +> *"I'm going to stop praying to corpses," he says. And then, with the ghost of a smile: "I'm going to start praying to us."* + +**Purpose:** Edric's crisis resolves into something new. Sets up his Z3 arc: founding a new spiritual movement. Connection to Vel: Brother Aldain references the Covenant suppressing god-death truth — the same suppression that led to Thornfield. + +--- + +## What Changed From Z1 + +| | Z1 (Dark Cave) | Z2 (Windy Peaks) | +|---|---|---| +| **Triggers** | Single action | Multi-step chains (2-4 steps) | +| **Verbs** | Kill, reach, spawn, PvP | Kill, explore, talk, escort, find, survive, examine | +| **NPC involvement** | Tal (1 fragment) | Vel (3 fragments), Edric (3 fragments) | +| **Narrative** | Standalone revelations | Interlocking character arcs | +| **Player agency** | Stumble into truth | Pursue truth through action | +| **Quest items** | None | Sealed Letter, Last Sermon (permanent mementos) | +| **Cross-chain connection** | None | Vel and Edric chains share thematic revelation | + +--- + +## Z2 UI/UX: Chain Progress + +### On the Game Board + +The **FragmentChainProgress** panel (StatsPanel, left side) shows chain completion: + +``` +┌─────────────────────────────────────────────────┐ +│ FRAGMENTS IX-XVI 3/8 │ +│ │ +│ THE PEAKS │ +│ IX The Ascent ● ✓ │ +│ XVI The Wind's Memory ○ ○ ○ 🔒 (4+ frags) │ +│ │ +│ VEL'S SHADOW │ +│ X Vel's Warning ● ● ✓ │ +│ XI The Orders ◉ ○ ← current │ +│ XII What She Left Behind ○ ○ ○ │ +│ │ +│ EDRIC'S TRIAL │ +│ XIII The Shrine ○ ○ ○ │ +│ XIV The Heretic's Q. ○ ○ │ +│ XV Bones of Faith ○ ○ ○ │ +│ │ +│ Current objective: │ +│ "Kill a Covenant Tracker" │ +└─────────────────────────────────────────────────┘ +``` + +### Quest Items in Inventory + +New section on Character page after Consumables: + +``` +┌─────────────────────────────────────────────────┐ +│ QUEST ITEMS │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 📜 Sealed Letter │ │ +│ │ Rarity: Uncommon │ │ +│ │ "Terminate with prejudice. │ │ +│ │ Signed: Commander Lias Coryn" │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ 📜 Last Sermon │ │ +│ │ Rarity: Uncommon │ │ +│ │ "If mortals outlive gods, then │ │ +│ │ perhaps we were always the │ │ +│ │ miracle." │ │ +│ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +### Fragment Hints (for undiscovered Z2) + +| Fragment | Hint Text | +|----------|-----------| +| IX - The Ascent | "The light awaits beyond the Marrow..." | +| X - Vel's Warning | "The Blade watches from the ridgeline..." | +| XI - The Orders | "Covenant hunters carry sealed orders..." | +| XII - What She Left Behind | "An abandoned camp holds a soldier's confession..." | +| XIII - The Shrine | "A god of war's shrine stands in the peaks..." | +| XIV - The Heretic's Question | "The Mender seeks answers at the altar..." | +| XV - Bones of Faith | "The dead kept their faith longer than the living..." | +| XVI - The Wind's Memory | "The summit remembers everyone who climbed..." | + +--- + ## Technical Implementation -*To be defined: Smart contract structure, NFT metadata, minting triggers.* +### Smart Contract Architecture + +**Fragment chain system** (`FragmentChainSystem.sol`): +- `tryAdvanceChain(characterId, fragmentType, triggerType, triggerData)` — validates and advances chain steps +- `setChainStep(fragmentType, stepIndex, triggerType, triggerData, narrative)` — admin config +- `initializeCharacterChain(characterId, fragmentType, totalSteps)` — called on zone entry +- Trigger types: `TileVisit(0)`, `CombatKill(1)`, `NpcInteract(2)` + +**Tables:** +- `FragmentChainProgress` (characterId + fragmentType) → currentStep, totalSteps, completed +- `FragmentChainStep` (fragmentType + stepIndex) → triggerType, triggerData, narrative +- `FragmentChainStepReward` (fragmentType + stepIndex) → rewardItemId (quest item drops) + +**Token ID generation:** Same as Z1: `tokenId = fragmentType * 1_000_000 + characterTokenId` + +**Zone entry:** `ZoneTransitionSystem.transitionZone()` initializes all 8 Z2 chains and auto-completes Fragment IX. + +**Fragment XVI prerequisite:** `tryAdvanceChain()` checks if 4+ of fragments IX-XV are claimed before allowing advancement. --- -*Last updated: March 9, 2026* +*Last updated: March 27, 2026* diff --git a/docs/MONSTER_ROSTER_REDESIGN.md b/docs/MONSTER_ROSTER_REDESIGN.md new file mode 100644 index 000000000..5093caa71 --- /dev/null +++ b/docs/MONSTER_ROSTER_REDESIGN.md @@ -0,0 +1,93 @@ +# Monster Roster Redesign + +Replacing custom/invented monsters with classic D&D/RPG archetypes. +Neither Z1 nor Z2 monsters are finalized on-chain — this is the time to get them right. + +## Design Principles +- Classic fantasy archetypes players recognize instantly (D&D, WoW, Keep on the Borderlands) +- Power scale should feel natural — kobolds are scrappy, basilisks are terrifying +- Dragons/drakes reserved for special encounters, not regular mobs +- Bosses are ~2 levels above the zone cap + +--- + +## Zone 1: Dark Cave (Levels 1-10) + +| Lvl | Current (live) | New | Class | Notes | +|-----|---------------|-----|-------|-------| +| 1 | Dire Rat | **Dire Rat** | AGI | Keep — classic starter. Art done (skeleton system). | +| 2 | Fungal Shaman | **Kobold** | INT | Art done (skeleton system, extreme crouch, spear). | +| 3 | Cavern Brute | **Goblin** | STR | Sword + shield, sneering. Classic cave fodder. | +| 4 | Crystal Elemental | **Giant Spider** | AGI | Web, fangs, 8 legs. Phase Spider art can be adapted. | +| 5 | Ironhide Troll | **Skeleton** | STR | Sword + tattered armor. Undead — fits cave lore. | +| 6 | Phase Spider | **Goblin Shaman** | INT | Upgraded goblin with staff + magic effects. | +| 7 | Bonecaster | **Gelatinous Ooze** | INT | Translucent cube/blob. Unique silhouette. | +| 8 | Rock Golem | **Bugbear** | STR | Big, hairy, club. The "oh shit" goblinoid. | +| 9 | Pale Stalker | **Carrion Crawler** | AGI | Multi-legged centipede thing. Creepy. | +| 10 | Dusk Drake | **Hook Horror** | STR | Armored, hook-clawed. Iconic Underdark creature. | +| 12 | Basilisk (boss) | **Basilisk** | STR | Keep — petrifying gaze boss. May need art refresh. | + +### Art Status +- [x] Dire Rat — skeleton system, quadruped, red-orange eyes, fur texture +- [x] Kobold — skeleton system, extreme crouch, spear thrust, S-curve tail +- [x] Goblin — skeleton system, oversized head, huge ears, two-handed axe overhead +- [x] Giant Spider — modified skeleton (5-node spine, 8 limbs), cluster of red eyes, wide leg spread +- [x] Skeleton — skeleton system, ribcage with dark gaps, skull brow ridge, sword up +- [x] Goblin Shaman — skeleton system, pointed hat, staff with glowing amber orb, big ears, tattered robes +- [x] Gelatinous Ooze — no skeleton (pure canvas), translucent cube, dissolved skull/bones/dagger inside +- [ ] Bugbear — large muscular goblinoid, shaggy fur, bear-like snout, massive club +- [ ] Carrion Crawler — segmented worm body, multiple legs (centipede), face tentacles +- [ ] Hook Horror — bird-like head, hooked beak, massive hook-claws, exoskeleton +- [ ] Basilisk — large lizard/serpent, petrifying gaze eyes (bright), thick scales, heavy tail + +--- + +## Zone 2: Windy Peaks (Levels 11-20) + +| Lvl | Current (placeholder) | New | Class | Notes | +|-----|----------------------|-----|-------|-------| +| 11 | Ridge Stalker | **Dire Wolf** | AGI | Pack hunter. Mountain variant. | +| 12 | Frost Wraith | **Harpy** | INT | Flying, shrieking. Fits mountain peaks. | +| 13 | Granite Sentinel | **Ogre** | STR | Big, dumb, hits hard. Classic mid-tier brute. | +| 14 | Gale Phantom | **Worg** | AGI | Giant evil wolf. Smarter than dire wolf. | +| 15 | Blighthorn | **Orc** | STR | Armored, disciplined. The real threat. | +| 16 | Storm Shrike | **Orc Shaman** | INT | Orc with elemental magic. | +| 17 | Hollow Scout | **Troll** | AGI | Regenerating. Classic mid-high threat. | +| 18 | Ironpeak Charger | **Griffon** | STR | Lion + eagle. Majestic but territorial. | +| 19 | Peakfire Wraith | **Manticore** | INT | Lion + scorpion tail + wings. Deadly. | +| 20 | — | **Wyvern** | STR | Proto-dragon. The zone capstone. | +| 22 | Korrath's Warden (boss) | **Korrath's Warden** | STR | Keep name — fits lore. ~2 levels above zone cap. | + +### Art Status +- [ ] Dire Wolf +- [ ] Harpy +- [ ] Ogre +- [ ] Worg +- [ ] Orc +- [ ] Orc Shaman +- [ ] Troll +- [ ] Griffon +- [ ] Manticore +- [ ] Wyvern +- [ ] Korrath's Warden + +--- + +## Migration Notes + +### What needs to change per monster: +1. **Art** — creature-lab iteration → port to `monsterTemplatesRedux.ts` +2. **monsters.json** — name, metadataUri, stats, inventoryNames +3. **items.json** — any monster-specific drops (e.g., "Dire Rat Fang" → new equivalents) +4. **Template array** — update `monsterTemplates` in monsterTemplatesRedux.ts +5. **On-chain sync** — `item-sync dark_cave --update` + verify + +### Stats approach: +- Keep the existing stat curves (they're balanced for progression) +- Just rename and re-theme — don't rebalance everything at once +- Boss stats stay the same, names may change + +### Items to watch: +- Monster-specific named drops (Sporecap Wand, Crystal Shard, etc.) need renaming +- Generic drops (potions, consumables, crafting mats) stay the same +- Signature weapons should match new monster identity diff --git a/docs/NEW_ZONE_CHECKLIST.md b/docs/NEW_ZONE_CHECKLIST.md index db9c6b1b5..30648cd2e 100644 --- a/docs/NEW_ZONE_CHECKLIST.md +++ b/docs/NEW_ZONE_CHECKLIST.md @@ -17,9 +17,9 @@ Everything in this phase is pen-and-paper / doc work. No code yet. Get the desig - [ ] Connection to next zone (how do players leave?) - [ ] Lore role — what do players LEARN in this zone? - [ ] Emotional arc (e.g., "hope → disillusionment → resolve") -- [ ] Is this a Wound, Decay zone, or civilization? +- [ ] Is this a Marrow, Fraying zone, or civilization? - [ ] Environmental description (what does it look, sound, smell like?) -- [ ] How the Decay manifests here specifically +- [ ] How the Fraying manifests here specifically ## 1.2 Naming Conventions @@ -380,7 +380,7 @@ Zone identity isn't just data — it's visual. Every zone should look and feel d ## 2.5 Fragment Art - [ ] Fragment card art or border design (consistent with zone theme) -- [ ] Memory Echo tile visual on the map +- [ ] Impression tile visual on the map --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 25108608c..018487c1d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -826,7 +826,7 @@ The table schema is already defined. If you want to build one of the unused syst 4. **Respect the dependency chain.** Features in higher tiers often depend on features in lower tiers. Check the "Dependencies" section for each feature before starting work. -5. **Test on beta first.** Beta world address: `0x4a54538eCD32E1827121f9edb4a87CC4C08536E5`. Deploy and validate there before touching production. See `docs/architecture/` for deployment guides. +5. **Test on beta first.** Beta world address: `0xDc34AC3b06fa0ed899696A72B7706369864E5678`. Deploy and validate there before touching production. See `docs/architecture/` for deployment guides. 6. **Keep the manifesto in mind.** Every system should make the world more permanent, more player-driven, and more worth coming back to in a year. If a feature doesn't pass that test, it doesn't ship. diff --git a/docs/WORKTREE_WORKFLOW.md b/docs/WORKTREE_WORKFLOW.md new file mode 100644 index 000000000..c77a44dcf --- /dev/null +++ b/docs/WORKTREE_WORKFLOW.md @@ -0,0 +1,116 @@ +# Worktree Workflow + +One-page cheat sheet for how Ultimate Dominion work flows through git worktrees, enforced by Claude Code hooks. + +## Mental model + +**One worktree = one branch = one workstream.** Worktrees are pinned to their branch for life. When a workstream is done, retire the whole thing (worktree + branch) together. Never branch-hop inside a worktree, never start feature work in the main checkout. + +- **Main checkout** (`~/ultimate-dominion`) — for prod operations, reviewing merged state, coordination. Stays on `main` or `dev`. No feature work. +- **Worktrees** (`~/ultimate-dominion/.claude/worktrees/`) — all feature work lives here, one per branch. + +## The commands you'll actually use + +| I want to... | Run this | +|---|---| +| Start new work | `./scripts/setup-worktree.sh [base-branch]` | +| Resume a worktree | `cd .claude/worktrees/ && claude -w` | +| See worktree health | `bash ~/.claude/scripts/wt-audit.sh` | +| Finish a worktree | `/handoff` (preferred) or `bash ~/.claude/scripts/wt-done.sh ` | +| Bypass an enforcement hook | Prefix with `WT_OVERRIDE=1` | + +## Lifecycle + +``` +1. setup-worktree.sh balance-sim dev + → .claude/worktrees/balance-sim/ (worktree/balance-sim branch) + +2. cd into it, launch claude, work, commit, push to dev + +3. PR → merge to dev + +4. /handoff (or wt-done worktree/balance-sim) + → audit runs, confirms merged, removes worktree + branch, + self-heals SESSION.md, writes HANDOFF_*.md, archives +``` + +## What's blocked (and why) + +These are enforced by `~/.claude/scripts/wt-enforce.sh`: + +| Command | Where | Why blocked | +|---|---|---| +| `git checkout -b` / `git switch -c` | everywhere | Use `setup-worktree.sh` so the new branch gets its own worktree | +| `git worktree remove` | everywhere | Use `wt-done.sh` — has dirty-state + merged-PR safety checks | +| `git branch -d` / `-D` | everywhere | Use `wt-done.sh` | +| `git checkout ` | in a worktree | Worktrees are pinned — start a session in a different worktree | +| `git checkout ` | in main checkout | Feature work belongs in a worktree | + +Always allowed: +- `git checkout main` / `dev` / `master` (in main checkout) +- `git checkout -- ` / `git checkout -- ` (path restore) +- `git worktree list` / `add` / `prune` +- `git branch -a` / listing operations +- All non-git operations + +**Escape hatch:** prefix any command with `WT_OVERRIDE=1` when you genuinely need to bypass. Example: `WT_OVERRIDE=1 git checkout abc123def` for archaeology. + +## What the SessionStart audit shows you + +Every new Claude session injects a `` block at the top showing: + +- All active worktrees with branch, dirty-file count, ahead/behind `dev` +- Local branches with no worktree (stragglers) +- SESSION.md drift (entries pointing to worktrees that no longer exist) +- Main checkout sanity warnings (on a feature branch? dirty?) + +If anything is flagged there, act on it before starting new work. + +## The /handoff phases + +`/handoff` now runs 9 phases in order. The first four are cleanup; the rest preserve the existing write/archive/direction flow: + +0. Commit any uncommitted work +1. Run `wt-audit` and embed output in the HANDOFF doc +2. Retire merged branches via `wt-done` (with per-branch confirmation) +3. Self-heal SESSION.md drift +4. Resolve main-checkout sanity warnings +5. Write `HANDOFF_[task-slug].md` to repo root +6. Copy to archive + add Obsidian wiki-links +7. Run `mem handoff ` to regenerate direction snapshot +8. Update SESSION.md to post-cleanup state +9. Delete HANDOFF file from repo root, suggest `/clear` prompt + +## Dirty-state chime + +When Claude finishes a response with uncommitted files, macOS notification fires with title `Claude Code — UNCOMMITTED` and the Basso sound. This is the closest substitute for hooking `/clear` (slash commands are client-side and can't be hooked). If you hear it, commit or `/handoff` before clearing. + +## Concurrent session discipline + +You run 2-4 Claude/Codex sessions at once. Rules: + +- **One session per worktree.** Multiple sessions in the same worktree will collide on file writes — no hook catches this. +- **Different worktrees = isolated.** Session A in `worktree/alpha` and session B in `worktree/beta` won't interfere. +- **Codex is not enforced.** These hooks live in `~/.claude/settings.json` — Codex runs its own config. Keep worktree discipline there manually. +- **Every session sees the shared state.** SessionStart injects the same audit everywhere — if session A left a mess, session B sees it immediately. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `BLOCKED [create-branch]` | Typed `git checkout -b` or `git switch -c` | Use `setup-worktree.sh` | +| `BLOCKED [worktree-switch]` | Tried to switch branches inside a worktree | Launch a session in a different worktree instead | +| `BLOCKED [main-switch]` | Tried to check out a feature branch in main checkout | Create a worktree for it | +| `BLOCKED [branch-delete]` | Ran `git branch -D` manually | Use `wt-done ` | +| `wt-done: ERROR: worktree has N dirty files` | Unpushed changes in the worktree | Commit, push, or stash first | +| `wt-done: ERROR: branch has N commits not on dev and no merged PR` | Work never landed | Push + merge the PR, or use WT_OVERRIDE if you really want to lose the commits | +| SessionStart audit shows stale SESSION.md entries | Old worktrees removed without updating SESSION.md | Run `wt-audit` and manually trim, or let next `/handoff` self-heal | + +## Related files + +- `~/.claude/scripts/wt-audit.sh` — worktree health audit +- `~/.claude/scripts/wt-done.sh` — safe worktree retirement +- `~/.claude/scripts/wt-enforce.sh` — PreToolUse Bash enforcement hook +- `~/.claude/commands/handoff.md` — 9-phase handoff protocol +- `~/.claude/settings.json` — hook registration +- `scripts/setup-worktree.sh` (repo) — new worktree bootstrap diff --git a/docs/architecture/CLIENT_SYNC.md b/docs/architecture/CLIENT_SYNC.md index a469a64a3..684c3935c 100644 --- a/docs/architecture/CLIENT_SYNC.md +++ b/docs/architecture/CLIENT_SYNC.md @@ -66,6 +66,34 @@ The `WSClient` maintains a persistent WebSocket connection to the indexer. All t 4. **When adding new UD-namespace tables**, they're automatically covered by receipt decoding (they're in `mudConfig.tables`). 5. **When adding new non-UD tables**, they'll be covered by delta + WS. No code change needed. +## Prod Ghost Mob Incident (April 2026) + +**What happened:** Prod started rendering attackable monsters that the contract immediately rejected with `No enemies here`. The first symptom looked like an indexer issue because fresh snapshot data still showed those mobs as spawned and on-tile. + +**What actually broke:** The client had two competing position sources: + +- Monsters and shops were being polluted by leaked `PositionV2` rows from the Zone 2 rollout. +- The player path still preferred `PositionV2` over legacy `Position`. +- The deployed PvE contract path (`MapSystem.isAtPosition` / `PvESystem.isValidPvE`) still validated combat against legacy `Position`. + +That meant the UI could believe the player and monsters were co-located while the contract checked a different tile. The indexer was not inventing the bug; it was faithfully serving stale-but-real mixed table state. + +**The fix:** On prod, prefer legacy `Position` for any path that must agree with on-chain encounter validation: + +- player position in `MapContext` +- player movement / auto-adventure in `createSystemCalls` +- click-time monster validation in `TileDetailsPanel` + +Keep using `PositionV2.zoneId` for zone membership filtering. Do not treat `PositionV2` as canonical for combat/movement until the deployed contract path does too. + +**Operational rule:** If prod shows ghost mobs again, compare all three before blaming the indexer: + +1. snapshot `Position` vs `PositionV2` for the player +2. snapshot `Spawned` / `EncounterEntity` / `Position` for the monster +3. the contract code path currently used for encounter validity + +If the client and contract are reading different position tables, the bug is in reconciliation logic even when the snapshot looks healthy. + --- -*Last updated: March 13, 2026* +*Last updated: April 2, 2026* diff --git a/docs/architecture/TOKEN_GUIDE.md b/docs/architecture/TOKEN_GUIDE.md index 88a42ebdb..0ee3e0af3 100644 --- a/docs/architecture/TOKEN_GUIDE.md +++ b/docs/architecture/TOKEN_GUIDE.md @@ -38,7 +38,7 @@ Each token is deployed idempotently — `PostDeploy` checks `UltimateDominionCon | Characters | `"UDCharacters"` | `"UDC"` | `"ipfs://"` | | Items | N/A (ERC1155) | N/A | `"ipfs://"` | | Badges | `"Ultimate Dominion Badges"` | `"UDB"` | `"ipfs://"` | -| Fragments | `"Fragments of the Fallen"` | `"FRAGMENT"` | `"ipfs://"` | +| Fragments | `"Fragments"` | `"FRAGMENT"` | `"ipfs://"` | --- @@ -162,7 +162,7 @@ Despite using the ERC721 puppet, badges behave like ERC1155 through a composite ### Fragments (ERC721) **Config field**: `UltimateDominionConfig.fragmentToken` -**Name**: "Fragments of the Fallen" +**Name**: "Fragments" **Transferable**: Yes (no NoTransferHook) Lore NFTs triggered by in-game actions (combat kills, tile discovery). 8 fragment types per zone. @@ -200,7 +200,7 @@ UltimateDominionConfig.getFragmentToken() ``` Production World: `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` -Beta World: `0x4a54538eCD32E1827121f9edb4a87CC4C08536E5` +Beta World: `0xDc34AC3b06fa0ed899696A72B7706369864E5678` Query token addresses by calling `UD__getCharacterToken()`, `UD__getGoldToken()`, or `UD__getItemsContract()` on the World. diff --git a/docs/direction/CURRENT.md b/docs/direction/CURRENT.md new file mode 100644 index 000000000..f72e28540 --- /dev/null +++ b/docs/direction/CURRENT.md @@ -0,0 +1,51 @@ +# Current Direction + +**This file is auto-generated on every `/handoff`.** It is the live +working memory for this project — what is actively being shipped, where +every thread stands, what changed recently. Agents (Claude, Codex, and +any other) should treat this as ground truth for "where are we right +now". Regenerated: `2026-04-17T20:32:56+00:00`. + +When this file conflicts with older docs, trust this one. + +--- +## Focus + +**Windy Peaks quest HUD + creature visibility shipped to beta 2026-04-17.** Three client bugs fixed (dragon off-map, fragment lookup encoding, echo icon) and contract prereq gate removed from ZoneTransitionSystem. Vercel auto-deploying from `dev`. Visual polish cluster (orange flash, HUD alignment, item ticker) merged through 2026-04-16. **Critical blocker:** SpellStats table empty — spells created without stats since ItemCreationSystem.createItem() has no Spell branch. Blocking prod deploy window. + +## Active threads + +- **[critical] SpellStats table empty** — last touched 2026-04-16. Spells created without stats; ItemCreationSystem.createItem() has no Spell branch. Needs: (1) add ItemType.Spell branch to createItem() writing to SpellStats, (2) migrate historical items (1-9, 214/232-257, 551-559). Next: implement branch + migration script, deploy to beta, test. + +- **[shipped] Windy Peaks quest HUD + dragon + fragment echo** — last touched 2026-04-17 (`d880474f`). Fixed 3 bugs: dragon coords (PositionV2 vs legacy Position math), fragment key encoding (decimal vs hex in encodeCompositeKey), echo icon missing from map. Contract fix (`abe5a30a`) drops prereq gate from ZoneTransitionSystem. Next: verify on beta, test quest pickup + HUD display + creature rendering. + +- **[shipped] Visual polish (orange flash + HUD + ticker)** — last touched 2026-04-16 (`f0dc23e6`). Orange shell flash eliminated via Header Box wrapper + pathname-aware RoutesFallback. Battle HUD snap fixed via stable HealthBar height + flex column. Item ticker removed. Next: verify on beta, no regressions. + +- **[shipped] Audio/SFX integration** — last touched 2026-04-14 (`92ca5564`). 50 tests passing, 13 OGG assets, merged to dev. Music ducking + SFX hooked on all surfaces (battle/loot/level/fragments). Next: verify audio and SFX playback on beta. + +- **[shipped] Zone unlock + creatures.json refactor** — last touched 2026-04-14 (`573a7ad1`, `1ed203ad`). Zone exit unlocks at L10 regardless of class state. Creatures.json extracted as single source; all Z1 creatures aligned (basilisk 26×9 → 18×18). Lab symlinks to canonical JSON. Next: verify basilisk and other creatures render correctly on beta. + +- **[deploy] Railway indexer ghost fix** — last touched 2026-04-14. Beta DB repaired (164 ghost rows cleared), code on dev (`2986663f`). Indexer deploy failed with 413 Payload Too Large. Next: redeploy `2986663f` to Railway indexer-beta-us with explicit Dockerfile config, verify snapshot clears stale positions. + +- **[beta-z2] Onboarding spawn speed** — last touched 2026-04-13. Spawn fires async after UI celebration/navigation, feels slower than movement/battle. Spawn not in fixed-gas map. Next: add UD__spawn to fixed-gas (2-4M), fire during post-enterGame celebration, gate nav on spawn receipt. + +- **[prod] Battle results hotfix** — last touched 2026-04-08 (`b13e4346`). Code ready. Next: push to GitHub so Vercel auto-deploys prod. + +## Recent pivots (last 14 days) + +- **2026-04-17** — Windy Peaks quest HUD shipped. Fixed dragon off-map (PositionV2 coords already zone-relative, don't subtract offset). Fixed fragment chain lookups (decimal string was being hex-encoded, need toString(16)). Fragment echo icon was dead code, now rendering. +- **2026-04-16** — Orange shell flash eliminated by wrapping Header Fragment in single Box (was leaking 2 children to Grid) + making RoutesFallback dark/pathname-aware. Item ticker removed from GameBoard. +- **2026-04-15** — Battle HUD snap eliminated by reserving HealthBar badges row unconditionally (minH="14px") and making battle container flex column. +- **2026-04-14** — Audio SFX fully integrated (13 OGG assets, 50 tests, all surfaces hooked). Creatures.json extracted as single source of truth for game and lab (all Z1 creatures had divergent grid dims). Respec relocated from Character modal to dedicated /respec page. Zone exit unlock decoupled from advanced class state. + +## Blocked / waiting + +- SpellStats population — needs ItemType.Spell branch implementation + migration +- Railway indexer deploy — `2986663f` failed with 413, awaits explicit Dockerfile redeploy +- Production battle hotfix — `b13e4346` ready to push +- Basilisk visual verification — user reported pixelated in Victory screen; verify post-creatures.json alignment + +## Open questions + +- **Basilisk display on beta** — is the 18×18 alignment now rendering correctly, or still pixelated/missing? +- **Spawn speed perception vs reality** — is spawn actually slower, or just feels slower because it fires async after UI navigation? diff --git a/docs/future/GUILDS.md b/docs/future/GUILDS.md index b9e18bd6b..16e9d0ef2 100644 --- a/docs/future/GUILDS.md +++ b/docs/future/GUILDS.md @@ -74,9 +74,9 @@ Guilds are called **Orders** in the game world. The term reflects the setting Orders are **independent of factions** (Covenant / Unbound). An Order can have members from both factions, or align entirely with one. This is a player decision, not a system restriction. However, Orders that align strongly with a faction may gain narrative recognition in the Living Chronicle. -### The Wound Compels Cooperation +### The Marrow Compels Cooperation -The Dark Cave — the Wound of Noctum — is hostile to individuals. Monsters spawn from corrupted divine essence. The deeper you go, the worse it gets. Lore-wise, survivors who band together last longer. The Orders are the in-world expression of this truth: alone, you are food. Together, you might survive long enough to matter. +The Dark Cave — the Marrow of Noctum — is hostile to individuals. Monsters spawn from corrupted divine essence. The deeper you go, the worse it gets. Lore-wise, survivors who band together last longer. The Orders are the in-world expression of this truth: alone, you are food. Together, you might survive long enough to matter. --- @@ -437,7 +437,7 @@ New lore fragments are unlockable through guild activities: | Fragment | Trigger | Content | |----------|---------|---------| | **The Pact** | Form a guild | "In the dark, the survivors learned what the gods never could — that strength is not solitary. The first pacts were sealed not in temples but in blood and trust." | -| **The Banner** | Claim first territory | "They drove a banner into corrupted soil and declared: this ground is ours. The Wound did not care. But the other survivors noticed." | +| **The Banner** | Claim first territory | "They drove a banner into corrupted soil and declared: this ground is ours. The Marrow did not care. But the other survivors noticed." | | **The Tax Collector** | Accumulate 10,000 gold in treasury (lifetime) | "Gold flows upward in every civilization. The question is always the same: does it flow back down?" | | **Brother's Keeper** | Approve 10 regear requests | "They buried their dead and re-armed the living. This is the oldest economy — loss shared is loss halved." | diff --git a/docs/future/POWER_SOURCE_ABILITIES.md b/docs/future/POWER_SOURCE_ABILITIES.md index 2a5ca1331..76e499064 100644 --- a/docs/future/POWER_SOURCE_ABILITIES.md +++ b/docs/future/POWER_SOURCE_ABILITIES.md @@ -473,7 +473,7 @@ This distinction matters narratively: class abilities are *trained*, power sourc ### The Three Sources of Power -In Noctum's Wound, where the gods are dead and magic is broken, three currents of power still flow: +In Noctum's Marrow, where the gods are dead and magic is broken, three currents of power still flow: **Divine** — The residue of dead gods. Faith echoes. Prayers still catch on something, even if no one's listening. Divine characters tap into the leftover channels of godly power — faith as infrastructure, long after its builders died. diff --git a/docs/operations/DEPLOY_RUNBOOK.md b/docs/operations/DEPLOY_RUNBOOK.md index 8aa514d9e..c56ace691 100644 --- a/docs/operations/DEPLOY_RUNBOOK.md +++ b/docs/operations/DEPLOY_RUNBOOK.md @@ -8,12 +8,25 @@ How to deploy every component of Ultimate Dominion, from contracts to infrastruc | Environment | Chain | Chain ID | World Address | Branch | Client URL | |-------------|-------|----------|---------------|--------|------------| -| **Local** | Anvil | 31337 | Auto-generated | Any | `http://localhost:3000` | -| **Beta** | Base Mainnet | 8453 | `0x4a54538eCD32E1827121f9edb4a87CC4C08536E5` | `dev` | `https://beta.ultimatedominion.com` | +| **Local** | Anvil (legacy/manual only) | 31337 | Auto-generated | Any | `http://localhost:3000` | +| **Beta** | Base Mainnet | 8453 | `0xDc34AC3b06fa0ed899696A72B7706369864E5678` | `dev` | `https://beta.ultimatedominion.com` | | **Production** | Base Mainnet | 8453 | `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` | `main` | `https://ultimatedominion.com` | Both beta and production run on Base Mainnet (chain 8453). They are distinguished **only** by world address. Never mix them. +### Railway Service Map + +All four Railway services live in the same Railway project (`sweet-quietude`). The beta services have legacy URL slugs containing "prod" — always use `railway service link ` to target the right one. + +| Service | Railway Name | Railway URL | World | +|---------|-------------|-------------|-------| +| Indexer (prod) | `indexer` | `indexer-production-d6df.up.railway.app` | `0x99d01939...` | +| Indexer (beta) | `indexer-beta` | `indexer-prod-production-45cf.up.railway.app` | `0xDc34AC3b...` | +| Relayer (prod) | `relayer` | `8453.relay.ultimatedominion.com` | `0x99d01939...` | +| Relayer (beta) | `relayer-beta` | `relayer-prod-production.up.railway.app` | `0xDc34AC3b...` | + +**Safety rule:** After every Railway deploy, `curl /api/health` (indexer) or `curl /` (relayer) and verify the `worldAddress` in the response matches the expected environment. + ### Environment Files (packages/contracts/) | File | Purpose | @@ -24,10 +37,33 @@ Both beta and production run on Base Mainnet (chain 8453). They are distinguishe Scripts source the correct `.env` file automatically. Forge admin scripts require manual sourcing: `source .env.testnet && forge script ...` +**Validation rule:** Do not use local Anvil as the default UD test gate. The normal path is compile locally, deploy/test on beta, then promote only after beta is verified. Local Anvil is a legacy/manual dev tool for isolated experiments only. + --- ## 2. Contract Deployment +### Beta CI Path (Standard) + +Use the GitHub Action for normal beta deploys. Do not run the legacy local Anvil test path as a gate. + +1. Compile locally: `pnpm --filter contracts run build` +2. Commit the logical change set. +3. Push the branch/commit to `dev`. +4. Run `.github/workflows/deploy-beta.yml` with `run_zone_loader=false` unless this is a fresh world. +5. Let the workflow finish. It runs, in order: + - `pnpm --filter contracts build` + - `mud deploy --profile=base-mainnet --worldAddress $BETA_WORLD_ADDRESS` + - `EnsureAccess.s.sol` with `FOUNDRY_PROFILE=script` + - optional zone loader for fresh worlds only + - fork-mode `PostDeploySmoke` against beta +6. If the workflow fails after `mud deploy`, assume beta has been mutated but is not validated. Fix the failing deploy helper, smoke test, or beta state, then rerun the beta workflow. +7. Classify smoke failures before changing code: + - Access grant failures usually belong in `script/EnsureAccess.s.sol`. + - `World_AccessDenied` from a smoke-only view call is often a smoke-test issue; prefer direct table reads for admin/fork checks. + - Missing system names must be checked against `mud.config.ts` / `.mud/local/systems.json`. + - Zero economic balances may be real beta state, not a contract deploy bug; repair with the documented admin script instead of weakening the gate. + ### Upgrade Deploy (Normal Case) This is the standard deployment path. It upgrades existing system contracts in-place while preserving all player data. @@ -182,15 +218,19 @@ A daily drip endpoint runs at 14:00 UTC: `GET /api/drip` (configured in `vercel. Custom MUD indexer that syncs Store events from on-chain to PostgreSQL, providing a REST API + WebSocket interface for the client. -| Item | Value | -|------|-------| -| Platform | Railway (Docker) | -| Package | `packages/indexer/` | -| Service ID | `61172447-73de-410a-943e-49ed3cc20d10` | -| URL | `https://indexer-production-d6df.up.railway.app` | -| Health | `GET /api/health` | -| Status | `GET /api/status` (full infra status) | -| Dashboard | `GET /dashboard` | +### Service Matrix + +| | **Production** | **Beta** | +|---|---|---| +| Railway service name | `indexer` | `indexer-beta` | +| Railway URL | `https://indexer-production-d6df.up.railway.app` | `https://indexer-prod-production-45cf.up.railway.app` | +| World address | `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` | `0xDc34AC3b06fa0ed899696A72B7706369864E5678` | +| Client env var | `.env.production` → `VITE_INDEXER_API_URL` | `.env.staging` → `VITE_INDEXER_API_URL` | +| Health | `GET /api/health` | `GET /api/health` | +| Status | `GET /api/status` | `GET /api/status` | +| Dashboard | `GET /dashboard` | `GET /dashboard` | + +> **Note:** The beta Railway URL contains "indexer-prod" — this is a legacy slug from when the service was renamed. The Railway service name `indexer-beta` is canonical. ### Deploying New Code @@ -200,19 +240,21 @@ Railway services are NOT connected to GitHub auto-deploy. Deploy manually: # Step 1: Build locally (catches declaration emit errors that tsc --noEmit misses) cd packages/indexer && pnpm build && cd ../.. -# Step 2: Upload and deploy -railway up --service indexer --detach +# Step 2: Link the correct service and deploy +railway service link indexer # Production +railway service link indexer-beta # Beta -# Step 3: Check build status IMMEDIATELY (railway up exits 0 even on failure) -RAILWAY_TOKEN=$(cat ~/.railway/config.json | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['token'])") -curl -s -H "Authorization: Bearer $RAILWAY_TOKEN" -H "Content-Type: application/json" \ - -d '{"query":"query { deployments(input: { serviceId: \"61172447-73de-410a-943e-49ed3cc20d10\" }) { edges { node { id status createdAt } } } }"}' \ - https://backboard.railway.com/graphql/v2 | python3 -c "import sys,json; [print(f'{e[\"node\"][\"id\"][:8]} {e[\"node\"][\"status\"]} {e[\"node\"][\"createdAt\"]}') for e in json.load(sys.stdin)['data']['deployments']['edges'][:3]]" +# Step 3: Upload and deploy +railway up --detach -# Step 4: Once status is SUCCESS, verify live endpoint -curl -s https://indexer-production-d6df.up.railway.app/api/health +# Step 4: Verify — ALWAYS check world address in health response matches expected env +curl -s https://indexer-production-d6df.up.railway.app/api/health # Production +curl -s https://indexer-prod-production-45cf.up.railway.app/api/health # Beta +# ↑ worldAddress in response MUST match the expected world for that env ``` +**CRITICAL: After every indexer deploy, verify the `worldAddress` in the health response. If it doesn't match the expected world, STOP — the service has wrong env vars.** + **Never use `railway redeploy`** — it reuses the old image and will NOT include your new code. ### Key Environment Variables (set in Railway dashboard) @@ -233,7 +275,10 @@ curl -s https://indexer-production-d6df.up.railway.app/api/health For standard system upgrades, no indexer changes needed — the indexer auto-syncs new events. Just verify health: ```bash +# Production curl -s https://indexer-production-d6df.up.railway.app/api/health +# Beta +curl -s https://indexer-prod-production-45cf.up.railway.app/api/health ``` For fresh world deploys, update `WORLD_ADDRESS` + `START_BLOCK` in Railway env vars and redeploy. May need a DB reset if schema changed significantly. @@ -246,13 +291,17 @@ Schema changes are handled dynamically — the indexer discovers tables from Pos Self-hosted transaction relayer with a pool of 5 EOA wallets. Pays gas on behalf of players using EIP-7702 embedded wallets. -| Item | Value | -|------|-------| -| Platform | Railway (Docker) | -| Package | `packages/relayer/` | -| Service ID | `dd62995a-cab5-4a98-b217-0c7bf111364e` | -| URL | `https://8453.relay.ultimatedominion.com` | -| Health | `GET /` (returns pool status + rpcStatus) | +### Service Matrix + +| | **Production** | **Beta** | +|---|---|---| +| Railway service name | `relayer` | `relayer-beta` | +| Railway URL | `https://8453.relay.ultimatedominion.com` | `https://relayer-prod-production.up.railway.app` | +| World address | `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` | `0xDc34AC3b06fa0ed899696A72B7706369864E5678` | +| Client env var | `.env.production` → `VITE_RELAYER_URL` | `.env.staging` → `VITE_RELAYER_URL` | +| Health | `GET /` | `GET /` | + +> **Note:** The beta Railway URL contains "relayer-prod" — legacy slug. The Railway service name `relayer-beta` is canonical. ### Deploying New Code @@ -262,17 +311,14 @@ Same pattern as indexer: # Step 1: Build locally cd packages/relayer && pnpm build && cd ../.. -# Step 2: Upload and deploy -railway up --service relayer --detach - -# Step 3: Check build status -RAILWAY_TOKEN=$(cat ~/.railway/config.json | python3 -c "import sys,json; print(json.load(sys.stdin)['user']['token'])") -curl -s -H "Authorization: Bearer $RAILWAY_TOKEN" -H "Content-Type: application/json" \ - -d '{"query":"query { deployments(input: { serviceId: \"dd62995a-cab5-4a98-b217-0c7bf111364e\" }) { edges { node { id status createdAt } } } }"}' \ - https://backboard.railway.com/graphql/v2 | python3 -c "import sys,json; [print(f'{e[\"node\"][\"id\"][:8]} {e[\"node\"][\"status\"]} {e[\"node\"][\"createdAt\"]}') for e in json.load(sys.stdin)['data']['deployments']['edges'][:3]]" +# Step 2: Link the correct service and deploy +railway service link relayer # Production +railway service link relayer-beta # Beta +railway up --detach -# Step 4: Verify -curl -s https://8453.relay.ultimatedominion.com/ | python3 -c "import sys,json; d=json.load(sys.stdin); print('rpcStatus:', d.get('rpcStatus',{}).get('active','MISSING'))" +# Step 3: Verify — check WORLD_ADDRESS in response matches expected env +curl -s https://8453.relay.ultimatedominion.com/ # Production +curl -s https://relayer-prod-production.up.railway.app/ # Beta ``` ### Key Environment Variables (set in Railway dashboard) @@ -369,18 +415,20 @@ pnpm item:verify:testnet dark_cave # Should show no diffs (or expected diffs) ### Indexer ```bash -# Health check — verify sync lag is low +# Production — verify worldAddress matches 0x99d01939... curl -s https://indexer-production-d6df.up.railway.app/api/health -# Full infra status -curl -s https://indexer-production-d6df.up.railway.app/api/status +# Beta — verify worldAddress matches 0xDc34AC3b... +curl -s https://indexer-prod-production-45cf.up.railway.app/api/health ``` ### Relayer ```bash -# Pool status + RPC connectivity +# Production curl -s https://8453.relay.ultimatedominion.com/ +# Beta +curl -s https://relayer-prod-production.up.railway.app/ ``` ### Base RPC @@ -531,6 +579,8 @@ Feature branch → PR to dev → CI + Smoke → Merge to dev ### Local Test Commands +UD's default validation does **not** run against local Anvil. Compile locally, then use beta fork/smoke/manual playtests for chain behavior. + ```bash # Client tests pnpm --filter client run test @@ -572,4 +622,4 @@ cd packages/contracts && pnpm test:fork:beta --- -*Last updated: March 17, 2026* +*Last updated: March 26, 2026* diff --git a/docs/operations/Z2_PHASED_DEPLOY_RUNBOOK.md b/docs/operations/Z2_PHASED_DEPLOY_RUNBOOK.md new file mode 100644 index 000000000..26faae743 --- /dev/null +++ b/docs/operations/Z2_PHASED_DEPLOY_RUNBOOK.md @@ -0,0 +1,268 @@ +# Z2 Production Deploy — Phased Runbook + +Each phase is independently deployable and rollbackable. Stop at any phase boundary if something breaks. Never combine phases into a single session. + +**Total commits:** ~100 (dev ahead of main) +**Key risk:** 14+ system upgrades, new tables, PositionV2 migration, zone data, effects, quest chains all at once +**Mitigation:** Phase separation + SHOW_Z2 gate means Z1 players are never exposed to Z2 risk + +--- + +## Pre-Flight (do this the day before) + +### Blockers — must fix before any deploy + +- [x] **Fix zone-loader mob counter** — Fixed in `3460172f`. Zone-loader now reads `Counters[world, 0]` directly via `getRecord` instead of calling non-existent `UD__getCurrentMobCounter`. +- [x] **Replace ConfigureZones.s.sol with viem script** — Fixed in `3460172f`. New script: `scripts/admin/configure-zones.ts`. Supports `--dry-run` and `--mainnet`. +- [x] **Build verify-zone-state script** — Fixed in `3460172f`. New script: `scripts/admin/verify-zone-state.ts`. Checks ZoneConfig, ZoneMapConfig, MobsByZoneLevel counts, mob ID ranges, monster inventories. Exits non-zero on failure. + +### Verify + +- [ ] Deployer ETH balance: `cast balance $DEPLOYER --rpc-url $PROD_RPC` — need ~0.05 ETH +- [ ] `worlds.json` has prod world address: `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` +- [ ] nginx `limit_conn` on rpc.ultimatedominion.com is >= 50 +- [ ] Read `memory/infra/deploy-guide.md` failure patterns table +- [ ] Read `memory/game/mud-gotchas.md` + +### Merge + +- [ ] Verify SHOW_Z2 gate is intact: `packages/client/src/lib/env.ts` → `export const SHOW_Z2 = !IS_PRODUCTION;` +- [ ] Clean build artifacts from dev: `.mud/local/systems.json`, `IWorld.abi.json`, `codegen/index.sol` +- [ ] PR from `dev` → `main` with full diff review +- [ ] **Do NOT remove SHOW_Z2 in this PR** — contracts deploy first, client goes live later + +--- + +## Phase 1: Contract Deploy (Z2 invisible to players) + +**Goal:** Get all system bytecode on-chain. Z2 is still gated — players see nothing new. +**Rollback:** Redeploy old system bytecode from previous main commit. Or just leave it — gated code does nothing. +**Duration:** ~15 min + +```bash +# On main branch, clean tree +cd packages/contracts +pnpm deploy:mainnet +``` + +### Verify + +```bash +# Check deploy output: +# - Correct world address (NOT a new one) +# - EnsureAccess ran +# - No Access_Denied errors in relayer logs (watch 5 min) +``` + +- [ ] Deploy output shows `0x99d01939F58B965E6E84a1D167E710Abdf5764b0` +- [ ] EnsureAccess completed (check deploy logs for "All cross-namespace access grants applied") +- [ ] **Z1 regression** — fight a mob, buy from shop, check marketplace. If anything breaks, STOP. + +### If Z1 breaks + +The PositionV2 migration touches MapSystem, MapSpawnSystem, EncounterSystem, AutoAdventureSystem, ShopSystem — all core gameplay. If Z1 combat/movement/shops break: + +1. Check relayer logs for `Access_Denied` → re-run `pnpm ensure-access:mainnet` +2. If still broken → `git checkout HEAD~1 -- packages/contracts/` and redeploy to restore old system bytecode +3. Do not proceed to Phase 2 + +--- + +## Phase 2: Zone Configuration (still invisible) + +**Goal:** Set up Z2 zone boundaries so the world knows where Z2 is. +**Rollback:** `setStaticField` ZoneMapConfig Z2 width=0 → tiles fall back to Z1. +**Duration:** ~10 min + +### Z2 ZoneMapConfig + +```bash +# Dry-run first to see current values: +source .env.mainnet && npx tsx scripts/admin/configure-zones.ts --mainnet --dry-run + +# If values need updating: +source .env.mainnet && npx tsx scripts/admin/configure-zones.ts --mainnet +``` + +The script uses `setStaticField` (NOT `setRecord` — that's a known no-op, failure #3). +It verifies each write by reading back the value — if verification fails, it exits immediately. + +- [ ] Z1 ZoneMapConfig: width=10, height=10, originX=0, originY=0, minLevel=1 +- [ ] Z2 ZoneMapConfig: width=10, height=10, originX=0, originY=100, minLevel=10 +- [ ] Z1 ZoneConfig: maxLevel=10, badgeBase=100 +- [ ] Z2 ZoneConfig: maxLevel=20, badgeBase=101 + +### Verify + +```bash +source .env.mainnet && npx tsx scripts/admin/verify-zone-state.ts dark_cave --mainnet +source .env.mainnet && npx tsx scripts/admin/verify-zone-state.ts windy_peaks --mainnet +``` + +- [ ] Both zones pass all checks +- [ ] Z1 regression — mobs still spawn correctly at existing tiles + +--- + +## Phase 3: Zone Content Loading + +**Goal:** Populate Z2 monsters, items, effects, shops, NPCs on-chain. +**Rollback:** Can't cleanly undo zone-loader (creates entities). But data is inert until Z2 is visible. +**Duration:** ~20 min +**CRITICAL:** Zone-loader runs ONCE. Re-running creates duplicates (failure #6). + +### Load + +```bash +pnpm zone:load:mainnet windy_peaks +``` + +### Verify immediately + +```bash +source .env.mainnet && npx tsx scripts/admin/verify-zone-state.ts windy_peaks --mainnet +``` + +The script automatically checks: +- MobsByZoneLevel mob counts match monsters.json +- Mob IDs are NOT in Z1 range (catches the counter bug) +- Monster inventories are non-empty + +- [ ] verify-zone-state passes all checks for windy_peaks +- [ ] Z2 monster inventories include Z1 consumables (Health Potion, Bloodrage Tonic) +- [ ] If zone-loader fails mid-run: **DO NOT re-run**. Diagnose first — re-running creates duplicates. + +### Sync & verify + +```bash +# Effects +npx tsx scripts/effect-sync.ts dark_cave # verify Z1 survived +npx tsx scripts/effect-sync.ts windy_peaks # verify Z2 loaded + +# Items +pnpm item:verify:mainnet dark_cave # verify Z1 +pnpm item:verify:mainnet windy_peaks # verify Z2 +``` + +- [ ] Z1 effects: 0 mismatched, 0 missing +- [ ] Z2 effects: 0 mismatched, 0 missing (24 total including wind_gust) +- [ ] Z1 items: 0 mismatched +- [ ] Z2 items: 0 mismatched + +### If anything mismatched + +```bash +# Fix with --update flag: +npx tsx scripts/effect-sync.ts windy_peaks --update +pnpm item:sync:mainnet windy_peaks +# Then re-verify +``` + +--- + +## Phase 4: Data Scripts (still invisible) + +**Goal:** Populate lookup tables, XP thresholds, class grants, boss config. +**Rollback:** Each script is idempotent — re-run with correct values. +**Duration:** ~15 min + +### Run in order + +```bash +# 1. XP thresholds for L11-20 +# Prod already has L1-10. Add L11-20: +# L11=26600, L12=28800, L13=31680, L14=35320, L15=39800 +# L16=45200, L17=51600, L18=59300, L19=68800, L20=84800 + +# 2. Class spell grants (scans URIStorage — never hardcodes IDs) +npx tsx scripts/admin/populate-class-spell-grants.ts --mainnet --dry-run +npx tsx scripts/admin/populate-class-spell-grants.ts --mainnet + +# 3. Patch Warden inventory (cross-zone items) +npx tsx scripts/admin/patch-warden-inventory.ts --mainnet --dry-run +npx tsx scripts/admin/patch-warden-inventory.ts --mainnet + +# 4. Configure World Boss +# configureWorldBoss(bossId=1, mobId=WARDEN_ID, zoneId=2, spawnX=5, spawnY=9, respawnSeconds=3600) + +# 5. Remove Warden from regular mob pool +# Remove from MobsByZoneLevel[2][20] if present + +# 6. Bump MAX_LEVEL to 20 (if not done in contract deploy) +# This is a constants.sol change — requires system redeploy if not already in the main merge +``` + +### Verify + +- [ ] Levels table has entries for L1-20 (all non-zero) +- [ ] LevelUnlockItems has 9 entries (one per class at L15) +- [ ] AdvancedClassItems has 9 entries (one per class at L10) +- [ ] WorldBoss configured and active +- [ ] Warden NOT in regular spawn pool +- [ ] **Z1 regression** — fight, shop, marketplace still work + +--- + +## Phase 5: Playtest on Prod (gated) + +**Goal:** Verify Z2 works with real prod data, real indexer, real relayer. +**How:** Temporarily enable Z2 for a test account by overriding the flag locally, or use beta as final proxy. + +- [ ] Full playthrough: new char → L10 → class select → zone transition → Z2 +- [ ] Z2 mob spawns at correct levels +- [ ] Z2 drops are Z2 items +- [ ] Wind gust applies on peak tiles (y >= 8) +- [ ] Warden spawns at fixed coords, double wind gust stacks +- [ ] Fragment chain initializes +- [ ] Pioneer badge mints +- [ ] Z2 NPCs visible and functional +- [ ] Run smoke test suite + +--- + +## Phase 6: Go Live (client deploy) + +**Goal:** Remove SHOW_Z2 gate. Z2 visible to all players. +**Rollback:** Re-add SHOW_Z2 gate, merge to main → Vercel auto-deploys. Instant. +**Duration:** ~5 min + +```bash +# Remove gate in Routes.tsx, Header.tsx, env.ts +# PR to main → merge → Vercel auto-deploys +``` + +- [ ] **Do NOT** use `vercel --prod` from CLI (failure #10) +- [ ] Use `printf` not `echo` for any env var changes (failure #11) +- [ ] Monitor for 30 min: relayer logs, indexer lag, player reports + +### Tag + +```bash +git tag deploy-prod-z2-$(date +%Y%m%d-%H%M) +``` + +--- + +## Emergency Rollback (any time after go-live) + +All reversible without contract redeploy: + +| Level | Action | Effect | Command | +|-------|--------|--------|---------| +| 1 | Disable Z2 spawns | No new mobs in Z2, existing mobs stay | `setStaticField ZoneMapConfig[2] width=0` | +| 2 | Block transitions | No new players enter Z2 | `setStaticField ZoneConfig[2] maxLevel=0` | +| 3 | Hide Z2 client | Z2 UI disappears entirely | Re-add SHOW_Z2 gate, merge to main | + +Z1 is never at risk — all Z2 content is additive. + +--- + +## Timing Recommendation + +Deploy during lowest traffic (check analytics). Each phase has a natural stop point. If you do one phase per session with verification between, total wall time is ~2 hours but spread across a day with confidence between each step. + +**Recommended schedule:** +- Morning: Phase 1 (contracts) + Phase 2 (zone config) — verify Z1 survives +- Afternoon: Phase 3 (zone load) + Phase 4 (data scripts) — verify Z2 data correct +- Next morning: Phase 5 (playtest) — catch anything missed +- When satisfied: Phase 6 (go live) diff --git a/docs/operations/launch_checklist.md b/docs/operations/launch_checklist.md index bf51e1813..eab26ecb8 100644 --- a/docs/operations/launch_checklist.md +++ b/docs/operations/launch_checklist.md @@ -47,7 +47,7 @@ Get the game playable with real users. Rough edges acceptable — the goal is re - [x] Gold withdrawal design ✓ MetaMask/external wallet users can transfer directly; embedded wallet users cannot withdraw (by design) **Narrative & Lore** -- [x] "Fragments of the Fallen" story arc ✓ 8-part story arc complete +- [x] "Fragments" story arc ✓ 8-part story arc complete - [x] FragmentSystem.sol ✓ ERC721 minting on claim - [x] Lore fragment triggers ✓ Client-side post-combat triggers (`def60c83`), spawn, shop, PvP, locations - [x] Badge system ✓ Adventurer badge at level 3, gates chat access @@ -177,6 +177,7 @@ Only after beta is stable, feedback is incorporated, and security is verified. - [x] Eliminate jank, stutters, and failed txs ✓ Auto-retry on reverts (`6ef6b5bd`, `acf453ba`), movement cooldown tracking (`145dbfb1`, `394664db`), move mutex (`52869919`), Alchemy Flashblocks (200ms blocks) - [x] Fire-and-forget gameplay actions ✓ (`6b100929`) — removed simulateContract blocking - [x] Optimistic progress bars ✓ (`01b17c94`) — asymptotic deceleration on all transactions +- [x] Ghost encounter hardening ✓ click-time stale-target validation plus authoritative combat bootstrap prevent stale cached PvE state from reviving battles; movement display sync now cleans/protects stale monster rows (`0b61f384`, `b0f87e60`, `4242f24f`, `a5e1d931`, `10db5e03`) **Chat** - [x] Show usernames in chat ✓ Class-colored character names (`bb442d1e`) @@ -383,6 +384,9 @@ New zones, items, and monsters can be added live via AdminTuning + zone loader w - [x] Gold Merchant (Stripe Checkout) ✓ - [x] Gold withdrawal design ✓ (MetaMask only, embedded wallet locked by design) - [x] sourcemap: false ✓ +- [x] Ghost mob prod hotfix ✓ stale client monster targets now reconcile against on-chain state before combat (`0b61f384`) +- [x] Ghost cleanup no longer clears whole prod tiles ✓ stale targets are evicted without hiding valid mobs on the same tile (`fc728097`) +- [x] Ghost encounter bootstrap fix on beta branch ✓ stale cached `CombatEncounter` rows no longer boot as authoritative UI state, and client no longer force-ends encounters (`b0f87e60`) ### Should Do - [ ] Verify contracts on Basescan @@ -417,4 +421,4 @@ New zones, items, and monsters can be added live via AdminTuning + zone loader w --- -_Last updated: March 11, 2026_ +_Last updated: April 7, 2026_ diff --git a/package.json b/package.json index 345d939a7..e4cd32610 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ }, "pnpm": { "patchedDependencies": { - "@latticexyz/cli@2.2.23": "patches/@latticexyz__cli@2.2.23.patch" + "@latticexyz/cli@2.2.23": "patches/@latticexyz__cli@2.2.23.patch", + "@latticexyz/protocol-parser@2.2.23": "patches/@latticexyz__protocol-parser@2.2.23.patch" } } } diff --git a/packages/api/.env.sample b/packages/api/.env.sample index 533187f7b..e3d89ac54 100644 --- a/packages/api/.env.sample +++ b/packages/api/.env.sample @@ -22,14 +22,14 @@ API_PORT=3001 # PRIVATE_KEY=0x_your_deployer_key # WORLD_ADDRESS=0x_your_beta_world_address # INITIAL_BLOCK_NUMBER=0 -# RPC_HTTP_URL=https://base.drpc.org +# RPC_HTTP_URL=https://rpc.ultimatedominion.com?token=YOUR_TOKEN # --- Example .env.mainnet --- # CHAIN_ID=8453 # PRIVATE_KEY=0x_your_mainnet_key # WORLD_ADDRESS=0x_your_mainnet_world_address # INITIAL_BLOCK_NUMBER=0 -# RPC_HTTP_URL=https://mainnet.base.org +# RPC_HTTP_URL=https://rpc.ultimatedominion.com # Resend (email signups + drip sequence) RESEND_API_KEY=re_your_api_key_here diff --git a/packages/api/api/paymasterWebhook.ts b/packages/api/api/paymasterWebhook.ts index a66e631cd..dfa532017 100644 --- a/packages/api/api/paymasterWebhook.ts +++ b/packages/api/api/paymasterWebhook.ts @@ -12,7 +12,7 @@ import { base } from 'viem/chains'; */ const WORLD_ADDRESS = process.env.WORLD_ADDRESS as Address; -const RPC_URL = process.env.RPC_HTTP_URL || 'https://mainnet.base.org'; +const RPC_URL = process.env.RPC_HTTP_URL || 'https://rpc.ultimatedominion.com'; const GAS_STATION_MIN_LEVEL = 3; // ABI fragments for reading character data from the World contract diff --git a/packages/api/lib/getNetworkConfig.ts b/packages/api/lib/getNetworkConfig.ts index 88396bfeb..053d4e4dc 100644 --- a/packages/api/lib/getNetworkConfig.ts +++ b/packages/api/lib/getNetworkConfig.ts @@ -27,12 +27,12 @@ const base: MUDChain = { nativeCurrency: { decimals: 18, name: "Ether", symbol: "ETH" }, rpcUrls: { default: { - http: [(process.env.RPC_HTTP_URL || "https://mainnet.base.org") as string], - webSocket: process.env.RPC_WS_URL ? [process.env.RPC_WS_URL] : ["wss://base-rpc.publicnode.com"], + http: [(process.env.RPC_HTTP_URL || "https://rpc.ultimatedominion.com") as string], + webSocket: process.env.RPC_WS_URL ? [process.env.RPC_WS_URL] : ["wss://rpc.ultimatedominion.com"], }, public: { - http: [(process.env.RPC_HTTP_URL || "https://mainnet.base.org") as string], - webSocket: process.env.RPC_WS_URL ? [process.env.RPC_WS_URL] : ["wss://base-rpc.publicnode.com"], + http: [(process.env.RPC_HTTP_URL || "https://rpc.ultimatedominion.com") as string], + webSocket: process.env.RPC_WS_URL ? [process.env.RPC_WS_URL] : ["wss://rpc.ultimatedominion.com"], }, }, blockExplorers: { diff --git a/packages/blog/CONTENT_PLAN.md b/packages/blog/CONTENT_PLAN.md index ce88184c1..3ea3f55a5 100644 --- a/packages/blog/CONTENT_PLAN.md +++ b/packages/blog/CONTENT_PLAN.md @@ -10,7 +10,7 @@ First 10 posts to establish what Ultimate Dominion is. Each stands alone but bui 2. **What You Actually Do in Ultimate Dominion** — The gameplay loop. Open a URL, make a character, fight monsters, find gear, trade, talk. No tech, no pitch. Just what happens when you play. -3. **The Dark Cave** — Worldbuilding. The grid, the atmosphere, the Wound, the monsters that used to be people. Noctum is dead and death is broken. Pure lore/fiction. +3. **The Dark Cave** — Worldbuilding. The grid, the atmosphere, the Marrow, the monsters that used to be people. Noctum is dead and death is broken. Pure lore/fiction. 4. **Nine Classes, One Cave** — Character creation, races, power sources, the 9 advanced classes. Why any class can be picked regardless of build. Build theory. @@ -20,7 +20,7 @@ First 10 posts to establish what Ultimate Dominion is. Each stands alone but bui 7. **Gold, Sinks, and Why Your Sword Breaks** — How the economy works. Infinite gold, finite items. Where gold goes when you die. The marketplace fee. Inspired by EVE/Runescape/Neopets. -8. **Fragments of the Fallen** — The 8 lore fragments. What they reveal. The story of the gods, the murder, the mystery. Makes people want to collect them all. +8. **Fragments** — The 8 lore fragments. What they reveal. The story of the gods, the murder, the mystery. Makes people want to collect them all. 9. **Open a URL and You're Playing** — Why browser-based, why no download, why no app store. Your friend sends you a link, you're in the same world in 30 seconds. diff --git a/packages/client/.env.sample b/packages/client/.env.sample index dfd9165cc..6d438ccf0 100644 --- a/packages/client/.env.sample +++ b/packages/client/.env.sample @@ -14,9 +14,7 @@ # pnpm build → .env + .env.production (mainnet build) # --- .env (shared, all modes) --- -VITE_WALLET_CONNECT_PROJECT_ID=your_project_id_here VITE_PRIVY_APP_ID=your_privy_app_id -VITE_BADGE_CONTRACT_ADDRESS= # --- .env.development (local) --- # VITE_CHAIN_ID=31337 @@ -27,15 +25,16 @@ VITE_BADGE_CONTRACT_ADDRESS= # --- .env.staging (beta — Base mainnet, separate world) --- # VITE_CHAIN_ID=8453 -# VITE_HTTPS_RPC_URL=https://base.drpc.org -# VITE_WS_RPC_URL=wss://base.drpc.org +# VITE_HTTPS_RPC_URL=https://rpc.ultimatedominion.com +# VITE_WS_RPC_URL=wss://rpc.ultimatedominion.com # VITE_WORLD_ADDRESS=0x_your_beta_world_address # VITE_INDEXER_URL= # VITE_API_URL=https://ultimate-dominion-api-iota.vercel.app # --- .env.production (mainnet) --- # VITE_CHAIN_ID=8453 -# VITE_HTTPS_RPC_URL=https://mainnet.base.org -# VITE_WS_RPC_URL=wss://mainnet.base.org +# VITE_HTTPS_RPC_URL=https://rpc.ultimatedominion.com +# VITE_WS_RPC_URL=wss://rpc.ultimatedominion.com +# VITE_WORLD_ADDRESS=0x_your_mainnet_world_address # VITE_INDEXER_URL= # VITE_API_URL=https://your-mainnet-api.vercel.app diff --git a/packages/client/index.html b/packages/client/index.html index 00af7629b..59a39b9d9 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -25,6 +25,9 @@ + + + diff --git a/packages/client/package.json b/packages/client/package.json index b87eec9ae..7ac05ff16 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -18,23 +18,27 @@ }, "dependencies": { "@chakra-ui/react": "^2.8.2", + "@chenglou/pretext": "^0.0.3", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/cinzel": "^5.2.8", "@fontsource/cormorant-garamond": "^5.2.11", "@fontsource/fira-code": "^5.1.0", "@fontsource/inter": "^5.2.8", + "@fontsource/noto-sans-jp": "^5.2.9", + "@fontsource/noto-sans-kr": "^5.2.9", + "@fontsource/noto-sans-sc": "^5.2.9", "@latticexyz/common": "2.2.23", "@latticexyz/protocol-parser": "2.2.23", "@latticexyz/store": "2.2.23", "@latticexyz/world": "2.2.23", "@marsidev/react-turnstile": "^1.4.2", "@privy-io/react-auth": "^2.4.1", - "@pushprotocol/restapi": "^1.7.25", "@rainbow-me/rainbowkit": "^2.1.1", "@tanstack/react-query": "^5.37.1", "@types/fuzzy-search": "^2.1.5", "@types/react-router-dom": "^5.3.3", + "@types/three": "^0.183.1", "@vercel/analytics": "^1.3.1", "@vercel/og": "^0.11.1", "buffer": "^6.0.3", @@ -42,14 +46,18 @@ "framer-motion": "^11.2.6", "fuzzy-search": "^3.2.1", "howler": "^2.2.4", + "i18next": "^25.10.10", + "i18next-browser-languagedetector": "^8.2.1", "next": "^14.2.35", "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet-async": "^2.0.5", + "react-i18next": "^16.6.6", "react-icons": "^5.2.1", "react-router-dom": "^6.23.1", "react-typist": "^2.0.5", "rxjs": "7.5.5", + "three": "^0.183.2", "use-sound": "^5.0.0", "viem": "2.35.1", "wagmi": "^2.9.6", diff --git a/packages/client/public/audio/battle-dark-cave-mix.ogg b/packages/client/public/audio/battle-dark-cave-mix.ogg new file mode 100644 index 000000000..75c5e7130 Binary files /dev/null and b/packages/client/public/audio/battle-dark-cave-mix.ogg differ diff --git a/packages/client/public/audio/battle-windy-peaks-mix.ogg b/packages/client/public/audio/battle-windy-peaks-mix.ogg new file mode 100644 index 000000000..a078b5ac6 Binary files /dev/null and b/packages/client/public/audio/battle-windy-peaks-mix.ogg differ diff --git a/packages/client/public/audio/cave-melody.ogg b/packages/client/public/audio/cave-melody.ogg deleted file mode 100644 index f06d7357b..000000000 Binary files a/packages/client/public/audio/cave-melody.ogg and /dev/null differ diff --git a/packages/client/public/audio/dark-cave-mix.ogg b/packages/client/public/audio/dark-cave-mix.ogg new file mode 100644 index 000000000..f1a5c134d Binary files /dev/null and b/packages/client/public/audio/dark-cave-mix.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-crit.ogg b/packages/client/public/audio/sfx/battle/battle-crit.ogg new file mode 100644 index 000000000..f9fe200ec Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-crit.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-hit-arrow.ogg b/packages/client/public/audio/sfx/battle/battle-hit-arrow.ogg new file mode 100644 index 000000000..bb5d68cc4 Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-hit-arrow.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-hit-hammer.ogg b/packages/client/public/audio/sfx/battle/battle-hit-hammer.ogg new file mode 100644 index 000000000..682a7be0a Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-hit-hammer.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-hit-magic.ogg b/packages/client/public/audio/sfx/battle/battle-hit-magic.ogg new file mode 100644 index 000000000..f445bc8d3 Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-hit-magic.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-hit-sword.ogg b/packages/client/public/audio/sfx/battle/battle-hit-sword.ogg new file mode 100644 index 000000000..9a0025409 Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-hit-sword.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-kill.ogg b/packages/client/public/audio/sfx/battle/battle-kill.ogg new file mode 100644 index 000000000..f02fd6d44 Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-kill.ogg differ diff --git a/packages/client/public/audio/sfx/battle/battle-win.ogg b/packages/client/public/audio/sfx/battle/battle-win.ogg new file mode 100644 index 000000000..ef141281f Binary files /dev/null and b/packages/client/public/audio/sfx/battle/battle-win.ogg differ diff --git a/packages/client/public/audio/sfx/battle/player-death.ogg b/packages/client/public/audio/sfx/battle/player-death.ogg new file mode 100644 index 000000000..ea9f9b184 Binary files /dev/null and b/packages/client/public/audio/sfx/battle/player-death.ogg differ diff --git a/packages/client/public/audio/sfx/fragment/fragment-claim.ogg b/packages/client/public/audio/sfx/fragment/fragment-claim.ogg new file mode 100644 index 000000000..343749bc3 Binary files /dev/null and b/packages/client/public/audio/sfx/fragment/fragment-claim.ogg differ diff --git a/packages/client/public/audio/sfx/fragment/fragment-trigger.ogg b/packages/client/public/audio/sfx/fragment/fragment-trigger.ogg new file mode 100644 index 000000000..b5d1a8d05 Binary files /dev/null and b/packages/client/public/audio/sfx/fragment/fragment-trigger.ogg differ diff --git a/packages/client/public/audio/sfx/level/level-up.ogg b/packages/client/public/audio/sfx/level/level-up.ogg new file mode 100644 index 000000000..df95b47c9 Binary files /dev/null and b/packages/client/public/audio/sfx/level/level-up.ogg differ diff --git a/packages/client/public/audio/sfx/loot/loot-epic.ogg b/packages/client/public/audio/sfx/loot/loot-epic.ogg new file mode 100644 index 000000000..da97ef808 Binary files /dev/null and b/packages/client/public/audio/sfx/loot/loot-epic.ogg differ diff --git a/packages/client/public/audio/sfx/loot/loot-rare.ogg b/packages/client/public/audio/sfx/loot/loot-rare.ogg new file mode 100644 index 000000000..5e800620d Binary files /dev/null and b/packages/client/public/audio/sfx/loot/loot-rare.ogg differ diff --git a/packages/client/public/audio/windy-peaks-mix.ogg b/packages/client/public/audio/windy-peaks-mix.ogg new file mode 100644 index 000000000..f3e6a1d9d Binary files /dev/null and b/packages/client/public/audio/windy-peaks-mix.ogg differ diff --git a/packages/client/public/favicon.svg b/packages/client/public/favicon.svg index ddf5f1d29..c737346f1 100644 --- a/packages/client/public/favicon.svg +++ b/packages/client/public/favicon.svg @@ -1,768 +1,70 @@ - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/packages/client/public/images/ud-dragon.svg b/packages/client/public/images/ud-dragon.svg new file mode 100644 index 000000000..6efbdb12e --- /dev/null +++ b/packages/client/public/images/ud-dragon.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/client/public/images/ud-logo-dark-horizontal.svg b/packages/client/public/images/ud-logo-dark-horizontal.svg new file mode 100644 index 000000000..bb8959704 --- /dev/null +++ b/packages/client/public/images/ud-logo-dark-horizontal.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/client/public/images/ud-logo-dark.svg b/packages/client/public/images/ud-logo-dark.svg new file mode 100644 index 000000000..939658f3a --- /dev/null +++ b/packages/client/public/images/ud-logo-dark.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/client/public/llms.txt b/packages/client/public/llms.txt index 25461a633..5c9b31658 100644 --- a/packages/client/public/llms.txt +++ b/packages/client/public/llms.txt @@ -23,5 +23,16 @@ - [Blog](https://ultimatedominion.com/blog): Development updates and stories from the world - [The Game That Faded Away](https://ultimatedominion.com/blog/the-game-that-faded-away): Origin story — why Ultimate Dominion was built +## Languages + +Ultimate Dominion is available in English, Korean (한국어), Japanese (日本語), and Simplified Chinese (中文). + +- [Korean](https://ultimatedominion.com/?lang=ko): 한국어 버전 +- [Japanese](https://ultimatedominion.com/?lang=ja): 日本語版 +- [Chinese](https://ultimatedominion.com/?lang=zh): 中文版 +- [Korean FAQ](https://ultimatedominion.com/faq?lang=ko) +- [Japanese FAQ](https://ultimatedominion.com/faq?lang=ja) +- [Chinese FAQ](https://ultimatedominion.com/faq?lang=zh) + ## About - [FAQ](https://ultimatedominion.com/faq): Common questions about gameplay, blockchain integration, and getting started diff --git a/packages/client/public/models/creatures/basilisk.glb b/packages/client/public/models/creatures/basilisk.glb new file mode 100644 index 000000000..784e91a60 Binary files /dev/null and b/packages/client/public/models/creatures/basilisk.glb differ diff --git a/packages/client/public/models/creatures/bugbear.glb b/packages/client/public/models/creatures/bugbear.glb new file mode 100644 index 000000000..9f384803f Binary files /dev/null and b/packages/client/public/models/creatures/bugbear.glb differ diff --git a/packages/client/public/models/creatures/carrion-crawler.glb b/packages/client/public/models/creatures/carrion-crawler.glb new file mode 100644 index 000000000..455311ad8 Binary files /dev/null and b/packages/client/public/models/creatures/carrion-crawler.glb differ diff --git a/packages/client/public/models/creatures/dire-rat.glb b/packages/client/public/models/creatures/dire-rat.glb new file mode 100644 index 000000000..103836c6d Binary files /dev/null and b/packages/client/public/models/creatures/dire-rat.glb differ diff --git a/packages/client/public/models/creatures/dwarf-animated.glb b/packages/client/public/models/creatures/dwarf-animated.glb new file mode 100644 index 000000000..74cfc2cae Binary files /dev/null and b/packages/client/public/models/creatures/dwarf-animated.glb differ diff --git a/packages/client/public/models/creatures/elf-animated.glb b/packages/client/public/models/creatures/elf-animated.glb new file mode 100644 index 000000000..ac58e33a2 Binary files /dev/null and b/packages/client/public/models/creatures/elf-animated.glb differ diff --git a/packages/client/public/models/creatures/giant-spider.glb b/packages/client/public/models/creatures/giant-spider.glb new file mode 100644 index 000000000..e01a9ad06 Binary files /dev/null and b/packages/client/public/models/creatures/giant-spider.glb differ diff --git a/packages/client/public/models/creatures/goblin-shaman.glb b/packages/client/public/models/creatures/goblin-shaman.glb new file mode 100644 index 000000000..037120c36 Binary files /dev/null and b/packages/client/public/models/creatures/goblin-shaman.glb differ diff --git a/packages/client/public/models/creatures/goblin.glb b/packages/client/public/models/creatures/goblin.glb new file mode 100644 index 000000000..9ad9d5132 Binary files /dev/null and b/packages/client/public/models/creatures/goblin.glb differ diff --git a/packages/client/public/models/creatures/hook-horror.glb b/packages/client/public/models/creatures/hook-horror.glb new file mode 100644 index 000000000..e49097c8c Binary files /dev/null and b/packages/client/public/models/creatures/hook-horror.glb differ diff --git a/packages/client/public/models/creatures/human-animated.glb b/packages/client/public/models/creatures/human-animated.glb new file mode 100644 index 000000000..ca958c3b4 Binary files /dev/null and b/packages/client/public/models/creatures/human-animated.glb differ diff --git a/packages/client/public/models/creatures/kobold.glb b/packages/client/public/models/creatures/kobold.glb new file mode 100644 index 000000000..bb95aff09 Binary files /dev/null and b/packages/client/public/models/creatures/kobold.glb differ diff --git a/packages/client/public/models/creatures/skeleton.glb b/packages/client/public/models/creatures/skeleton.glb new file mode 100644 index 000000000..89a731407 Binary files /dev/null and b/packages/client/public/models/creatures/skeleton.glb differ diff --git a/packages/client/public/models/items/acolyte-vestments.glb b/packages/client/public/models/items/acolyte-vestments.glb new file mode 100644 index 000000000..6bc511e16 Binary files /dev/null and b/packages/client/public/models/items/acolyte-vestments.glb differ diff --git a/packages/client/public/models/items/apprentice-robes.glb b/packages/client/public/models/items/apprentice-robes.glb new file mode 100644 index 000000000..19317ad43 Binary files /dev/null and b/packages/client/public/models/items/apprentice-robes.glb differ diff --git a/packages/client/public/models/items/apprentice-staff.glb b/packages/client/public/models/items/apprentice-staff.glb new file mode 100644 index 000000000..353825503 Binary files /dev/null and b/packages/client/public/models/items/apprentice-staff.glb differ diff --git a/packages/client/public/models/items/bone-staff.glb b/packages/client/public/models/items/bone-staff.glb new file mode 100644 index 000000000..f98df3b51 Binary files /dev/null and b/packages/client/public/models/items/bone-staff.glb differ diff --git a/packages/client/public/models/items/broken-sword.glb b/packages/client/public/models/items/broken-sword.glb new file mode 100644 index 000000000..95251e10e Binary files /dev/null and b/packages/client/public/models/items/broken-sword.glb differ diff --git a/packages/client/public/models/items/carved-stone-plate.glb b/packages/client/public/models/items/carved-stone-plate.glb new file mode 100644 index 000000000..bfbc24f95 Binary files /dev/null and b/packages/client/public/models/items/carved-stone-plate.glb differ diff --git a/packages/client/public/models/items/channeling-rod.glb b/packages/client/public/models/items/channeling-rod.glb new file mode 100644 index 000000000..2a6f5c41f Binary files /dev/null and b/packages/client/public/models/items/channeling-rod.glb differ diff --git a/packages/client/public/models/items/cracked-wand.glb b/packages/client/public/models/items/cracked-wand.glb new file mode 100644 index 000000000..3381bd7ff Binary files /dev/null and b/packages/client/public/models/items/cracked-wand.glb differ diff --git a/packages/client/public/models/items/crystal-shard.glb b/packages/client/public/models/items/crystal-shard.glb new file mode 100644 index 000000000..090fcb66d Binary files /dev/null and b/packages/client/public/models/items/crystal-shard.glb differ diff --git a/packages/client/public/models/items/darkwood-bow.glb b/packages/client/public/models/items/darkwood-bow.glb new file mode 100644 index 000000000..59e389b05 Binary files /dev/null and b/packages/client/public/models/items/darkwood-bow.glb differ diff --git a/packages/client/public/models/items/dire-rat-fang.glb b/packages/client/public/models/items/dire-rat-fang.glb new file mode 100644 index 000000000..50a608d79 Binary files /dev/null and b/packages/client/public/models/items/dire-rat-fang.glb differ diff --git a/packages/client/public/models/items/drakes-cowl.glb b/packages/client/public/models/items/drakes-cowl.glb new file mode 100644 index 000000000..dc8eeeb01 Binary files /dev/null and b/packages/client/public/models/items/drakes-cowl.glb differ diff --git a/packages/client/public/models/items/drakescale-staff.glb b/packages/client/public/models/items/drakescale-staff.glb new file mode 100644 index 000000000..40d965930 Binary files /dev/null and b/packages/client/public/models/items/drakescale-staff.glb differ diff --git a/packages/client/public/models/items/etched-chainmail.glb b/packages/client/public/models/items/etched-chainmail.glb new file mode 100644 index 000000000..b101c2a57 Binary files /dev/null and b/packages/client/public/models/items/etched-chainmail.glb differ diff --git a/packages/client/public/models/items/gnarled-cudgel.glb b/packages/client/public/models/items/gnarled-cudgel.glb new file mode 100644 index 000000000..20e259b35 Binary files /dev/null and b/packages/client/public/models/items/gnarled-cudgel.glb differ diff --git a/packages/client/public/models/items/hunting-bow.glb b/packages/client/public/models/items/hunting-bow.glb new file mode 100644 index 000000000..17df2caad Binary files /dev/null and b/packages/client/public/models/items/hunting-bow.glb differ diff --git a/packages/client/public/models/items/iron-axe.glb b/packages/client/public/models/items/iron-axe.glb new file mode 100644 index 000000000..b1132b33e Binary files /dev/null and b/packages/client/public/models/items/iron-axe.glb differ diff --git a/packages/client/public/models/items/leather-jerkin.glb b/packages/client/public/models/items/leather-jerkin.glb new file mode 100644 index 000000000..92033eaf0 Binary files /dev/null and b/packages/client/public/models/items/leather-jerkin.glb differ diff --git a/packages/client/public/models/items/light-mace.glb b/packages/client/public/models/items/light-mace.glb new file mode 100644 index 000000000..821b40b2c Binary files /dev/null and b/packages/client/public/models/items/light-mace.glb differ diff --git a/packages/client/public/models/items/longbow.glb b/packages/client/public/models/items/longbow.glb new file mode 100644 index 000000000..3288db0f6 Binary files /dev/null and b/packages/client/public/models/items/longbow.glb differ diff --git a/packages/client/public/models/items/mage-robes.glb b/packages/client/public/models/items/mage-robes.glb new file mode 100644 index 000000000..eb37c468b Binary files /dev/null and b/packages/client/public/models/items/mage-robes.glb differ diff --git a/packages/client/public/models/items/mage-staff.glb b/packages/client/public/models/items/mage-staff.glb new file mode 100644 index 000000000..4c5e47853 Binary files /dev/null and b/packages/client/public/models/items/mage-staff.glb differ diff --git a/packages/client/public/models/items/manifest.json b/packages/client/public/models/items/manifest.json new file mode 100644 index 000000000..2569d6c80 --- /dev/null +++ b/packages/client/public/models/items/manifest.json @@ -0,0 +1,819 @@ +{ + "broken-sword": { + "name": "Broken Sword", + "file": "broken-sword.glb", + "category": "weapons", + "subtype": "sword", + "rarity": 0, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "worn-shortbow": { + "name": "Worn Shortbow", + "file": "worn-shortbow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 0, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "cracked-wand": { + "name": "Cracked Wand", + "file": "cracked-wand.glb", + "category": "weapons", + "subtype": "wand", + "rarity": 0, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "iron-axe": { + "name": "Iron Axe", + "file": "iron-axe.glb", + "category": "weapons", + "subtype": "axe", + "rarity": 1, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "hunting-bow": { + "name": "Hunting Bow", + "file": "hunting-bow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 1, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "apprentice-staff": { + "name": "Apprentice Staff", + "file": "apprentice-staff.glb", + "category": "weapons", + "subtype": "staff", + "rarity": 1, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "light-mace": { + "name": "Light Mace", + "file": "light-mace.glb", + "category": "weapons", + "subtype": "mace", + "rarity": 1, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "shortbow": { + "name": "Shortbow", + "file": "shortbow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 1, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "channeling-rod": { + "name": "Channeling Rod", + "file": "channeling-rod.glb", + "category": "weapons", + "subtype": "wand", + "rarity": 1, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "notched-blade": { + "name": "Notched Blade", + "file": "notched-blade.glb", + "category": "weapons", + "subtype": "sword", + "rarity": 1, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "warhammer": { + "name": "Warhammer", + "file": "warhammer.glb", + "category": "weapons", + "subtype": "mace", + "rarity": 2, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "longbow": { + "name": "Longbow", + "file": "longbow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 2, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "mage-staff": { + "name": "Mage Staff", + "file": "mage-staff.glb", + "category": "weapons", + "subtype": "staff", + "rarity": 2, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "dire-rat-fang": { + "name": "Dire Rat Fang", + "file": "dire-rat-fang.glb", + "category": "weapons", + "subtype": "dagger", + "rarity": 3, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "sporecap-wand": { + "name": "Sporecap Wand", + "file": "sporecap-wand.glb", + "category": "weapons", + "subtype": "wand", + "rarity": 2, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "notched-cleaver": { + "name": "Notched Cleaver", + "file": "notched-cleaver.glb", + "category": "weapons", + "subtype": "axe", + "rarity": 2, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "crystal-shard": { + "name": "Crystal Shard", + "file": "crystal-shard.glb", + "category": "weapons", + "subtype": "dagger", + "rarity": 2, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "gnarled-cudgel": { + "name": "Gnarled Cudgel", + "file": "gnarled-cudgel.glb", + "category": "weapons", + "subtype": "mace", + "rarity": 3, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "webspinner-bow": { + "name": "Webspinner Bow", + "file": "webspinner-bow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 2, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "bone-staff": { + "name": "Bone Staff", + "file": "bone-staff.glb", + "category": "weapons", + "subtype": "staff", + "rarity": 3, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "stone-maul": { + "name": "Stone Maul", + "file": "stone-maul.glb", + "category": "weapons", + "subtype": "mace", + "rarity": 3, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "darkwood-bow": { + "name": "Darkwood Bow", + "file": "darkwood-bow.glb", + "category": "weapons", + "subtype": "bow", + "rarity": 3, + "socket": "hand_L.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "smoldering-rod": { + "name": "Smoldering Rod", + "file": "smoldering-rod.glb", + "category": "weapons", + "subtype": "wand", + "rarity": 3, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "trollhide-cleaver": { + "name": "Trollhide Cleaver", + "file": "trollhide-cleaver.glb", + "category": "weapons", + "subtype": "axe", + "rarity": 4, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "phasefang": { + "name": "Phasefang", + "file": "phasefang.glb", + "category": "weapons", + "subtype": "dagger", + "rarity": 4, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "drakescale-staff": { + "name": "Drakescale Staff", + "file": "drakescale-staff.glb", + "category": "weapons", + "subtype": "staff", + "rarity": 4, + "socket": "hand_R.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "tattered-cloth": { + "name": "Tattered Cloth", + "file": "tattered-cloth.glb", + "category": "armor", + "subtype": "cloth", + "rarity": 0, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "worn-leather-vest": { + "name": "Worn Leather Vest", + "file": "worn-leather-vest.glb", + "category": "armor", + "subtype": "leather", + "rarity": 0, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "rusty-chainmail": { + "name": "Rusty Chainmail", + "file": "rusty-chainmail.glb", + "category": "armor", + "subtype": "plate", + "rarity": 0, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "padded-armor": { + "name": "Padded Armor", + "file": "padded-armor.glb", + "category": "armor", + "subtype": "plate", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "leather-jerkin": { + "name": "Leather Jerkin", + "file": "leather-jerkin.glb", + "category": "armor", + "subtype": "leather", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "apprentice-robes": { + "name": "Apprentice Robes", + "file": "apprentice-robes.glb", + "category": "armor", + "subtype": "cloth", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "studded-leather": { + "name": "Studded Leather", + "file": "studded-leather.glb", + "category": "armor", + "subtype": "plate", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "scout-armor": { + "name": "Scout Armor", + "file": "scout-armor.glb", + "category": "armor", + "subtype": "leather", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "acolyte-vestments": { + "name": "Acolyte Vestments", + "file": "acolyte-vestments.glb", + "category": "armor", + "subtype": "cloth", + "rarity": 1, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "etched-chainmail": { + "name": "Etched Chainmail", + "file": "etched-chainmail.glb", + "category": "armor", + "subtype": "plate", + "rarity": 2, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "ranger-leathers": { + "name": "Ranger Leathers", + "file": "ranger-leathers.glb", + "category": "armor", + "subtype": "leather", + "rarity": 2, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "mage-robes": { + "name": "Mage Robes", + "file": "mage-robes.glb", + "category": "armor", + "subtype": "cloth", + "rarity": 2, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "spider-silk-wraps": { + "name": "Spider Silk Wraps", + "file": "spider-silk-wraps.glb", + "category": "armor", + "subtype": "leather", + "rarity": 2, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "carved-stone-plate": { + "name": "Carved Stone Plate", + "file": "carved-stone-plate.glb", + "category": "armor", + "subtype": "plate", + "rarity": 3, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "stalkers-vest": { + "name": "Stalker's Vest", + "file": "stalkers-vest.glb", + "category": "armor", + "subtype": "leather", + "rarity": 3, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "scorched-scale-vest": { + "name": "Scorched Scale Vest", + "file": "scorched-scale-vest.glb", + "category": "armor", + "subtype": "plate", + "rarity": 3, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + }, + "drakes-cowl": { + "name": "Drake's Cowl", + "file": "drakes-cowl.glb", + "category": "armor", + "subtype": "cloth", + "rarity": 3, + "socket": "chest.socket", + "offset": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": 1 + } +} \ No newline at end of file diff --git a/packages/client/public/models/items/notched-blade.glb b/packages/client/public/models/items/notched-blade.glb new file mode 100644 index 000000000..b8a0f93af Binary files /dev/null and b/packages/client/public/models/items/notched-blade.glb differ diff --git a/packages/client/public/models/items/notched-cleaver.glb b/packages/client/public/models/items/notched-cleaver.glb new file mode 100644 index 000000000..69d438154 Binary files /dev/null and b/packages/client/public/models/items/notched-cleaver.glb differ diff --git a/packages/client/public/models/items/padded-armor.glb b/packages/client/public/models/items/padded-armor.glb new file mode 100644 index 000000000..8e3ff7b28 Binary files /dev/null and b/packages/client/public/models/items/padded-armor.glb differ diff --git a/packages/client/public/models/items/phasefang.glb b/packages/client/public/models/items/phasefang.glb new file mode 100644 index 000000000..bf42e53ab Binary files /dev/null and b/packages/client/public/models/items/phasefang.glb differ diff --git a/packages/client/public/models/items/ranger-leathers.glb b/packages/client/public/models/items/ranger-leathers.glb new file mode 100644 index 000000000..b364df3c1 Binary files /dev/null and b/packages/client/public/models/items/ranger-leathers.glb differ diff --git a/packages/client/public/models/items/rusty-chainmail.glb b/packages/client/public/models/items/rusty-chainmail.glb new file mode 100644 index 000000000..55f34b0c5 Binary files /dev/null and b/packages/client/public/models/items/rusty-chainmail.glb differ diff --git a/packages/client/public/models/items/scorched-scale-vest.glb b/packages/client/public/models/items/scorched-scale-vest.glb new file mode 100644 index 000000000..8ff05d581 Binary files /dev/null and b/packages/client/public/models/items/scorched-scale-vest.glb differ diff --git a/packages/client/public/models/items/scout-armor.glb b/packages/client/public/models/items/scout-armor.glb new file mode 100644 index 000000000..b6547ec0f Binary files /dev/null and b/packages/client/public/models/items/scout-armor.glb differ diff --git a/packages/client/public/models/items/shortbow.glb b/packages/client/public/models/items/shortbow.glb new file mode 100644 index 000000000..7771f7163 Binary files /dev/null and b/packages/client/public/models/items/shortbow.glb differ diff --git a/packages/client/public/models/items/smoldering-rod.glb b/packages/client/public/models/items/smoldering-rod.glb new file mode 100644 index 000000000..f81a7b75a Binary files /dev/null and b/packages/client/public/models/items/smoldering-rod.glb differ diff --git a/packages/client/public/models/items/spider-silk-wraps.glb b/packages/client/public/models/items/spider-silk-wraps.glb new file mode 100644 index 000000000..237640d03 Binary files /dev/null and b/packages/client/public/models/items/spider-silk-wraps.glb differ diff --git a/packages/client/public/models/items/sporecap-wand.glb b/packages/client/public/models/items/sporecap-wand.glb new file mode 100644 index 000000000..9c3c21d3b Binary files /dev/null and b/packages/client/public/models/items/sporecap-wand.glb differ diff --git a/packages/client/public/models/items/stalkers-vest.glb b/packages/client/public/models/items/stalkers-vest.glb new file mode 100644 index 000000000..4988b1eaf Binary files /dev/null and b/packages/client/public/models/items/stalkers-vest.glb differ diff --git a/packages/client/public/models/items/stone-maul.glb b/packages/client/public/models/items/stone-maul.glb new file mode 100644 index 000000000..3efccf70c Binary files /dev/null and b/packages/client/public/models/items/stone-maul.glb differ diff --git a/packages/client/public/models/items/studded-leather.glb b/packages/client/public/models/items/studded-leather.glb new file mode 100644 index 000000000..8f80e2513 Binary files /dev/null and b/packages/client/public/models/items/studded-leather.glb differ diff --git a/packages/client/public/models/items/tattered-cloth.glb b/packages/client/public/models/items/tattered-cloth.glb new file mode 100644 index 000000000..bb3cda3ce Binary files /dev/null and b/packages/client/public/models/items/tattered-cloth.glb differ diff --git a/packages/client/public/models/items/trollhide-cleaver.glb b/packages/client/public/models/items/trollhide-cleaver.glb new file mode 100644 index 000000000..834b45d38 Binary files /dev/null and b/packages/client/public/models/items/trollhide-cleaver.glb differ diff --git a/packages/client/public/models/items/warhammer.glb b/packages/client/public/models/items/warhammer.glb new file mode 100644 index 000000000..ebd478310 Binary files /dev/null and b/packages/client/public/models/items/warhammer.glb differ diff --git a/packages/client/public/models/items/webspinner-bow.glb b/packages/client/public/models/items/webspinner-bow.glb new file mode 100644 index 000000000..84f25154f Binary files /dev/null and b/packages/client/public/models/items/webspinner-bow.glb differ diff --git a/packages/client/public/models/items/worn-leather-vest.glb b/packages/client/public/models/items/worn-leather-vest.glb new file mode 100644 index 000000000..3579bca82 Binary files /dev/null and b/packages/client/public/models/items/worn-leather-vest.glb differ diff --git a/packages/client/public/models/items/worn-shortbow.glb b/packages/client/public/models/items/worn-shortbow.glb new file mode 100644 index 000000000..d085f2603 Binary files /dev/null and b/packages/client/public/models/items/worn-shortbow.glb differ diff --git a/packages/client/public/robots.txt b/packages/client/public/robots.txt index 3e615564c..eccd885a4 100644 --- a/packages/client/public/robots.txt +++ b/packages/client/public/robots.txt @@ -27,4 +27,8 @@ Allow: / User-agent: Bytespider Allow: / +# Korean search — Naver +User-agent: Yeti +Allow: / + Sitemap: https://ultimatedominion.com/sitemap.xml diff --git a/packages/client/public/sitemap-main.xml b/packages/client/public/sitemap-main.xml index 8ed909ceb..02369905d 100644 --- a/packages/client/public/sitemap-main.xml +++ b/packages/client/public/sitemap-main.xml @@ -1,27 +1,53 @@ - + https://ultimatedominion.com/ + + + + + weekly 1.0 https://ultimatedominion.com/manifesto + + + + + monthly 0.8 https://ultimatedominion.com/leaderboard + + + + + daily 0.7 https://ultimatedominion.com/marketplace + + + + + daily 0.7 https://ultimatedominion.com/faq + + + + + monthly 0.8 diff --git a/packages/client/scripts/create-chat-group.ts b/packages/client/scripts/create-chat-group.ts deleted file mode 100644 index df97184fb..000000000 --- a/packages/client/scripts/create-chat-group.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Create a token-gated Push Protocol group for Ultimate Dominion chat - * - * This script creates a Push Protocol group that requires ownership of - * the Adventurer badge (Token ID 1xxx...) to join. - * - * Usage: - * PRIVATE_KEY=0x... PUSH_ENV=prod CHAIN_ID=8453 npx ts-node scripts/create-chat-group.ts - * - * PUSH_ENV: 'prod' for production Push backend, 'staging' (default) for dev - * After running, set VITE_PUSH_GROUP_CHAT_ID env var to the output chat ID - */ - -import { CONSTANTS, PushAPI } from '@pushprotocol/restapi'; -import { createWalletClient, http } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; -import { anvil, base } from 'viem/chains'; - -const PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`; -const BADGE_CONTRACT = process.env.BADGE_CONTRACT; -const CHAIN_ID = process.env.CHAIN_ID || '31337'; -const PUSH_ENV = process.env.PUSH_ENV === 'prod' ? CONSTANTS.ENV.PROD : CONSTANTS.ENV.STAGING; - -if (!PRIVATE_KEY) { - console.error('Please set PRIVATE_KEY environment variable'); - process.exit(1); -} - -// Map chain IDs to viem chains -const chains: Record = { - '31337': anvil, - '8453': base, -}; - -async function createTokenGatedGroup() { - const account = privateKeyToAccount(PRIVATE_KEY); - const chain = chains[CHAIN_ID] || anvil; - - const walletClient = createWalletClient({ - account, - chain, - transport: http(), - }); - - console.log('Initializing Push Protocol...'); - console.log(' Account:', account.address); - console.log(' Chain ID:', CHAIN_ID); - console.log(' Push Env:', PUSH_ENV === CONSTANTS.ENV.PROD ? 'PROD' : 'STAGING'); - console.log(' Badge Contract:', BADGE_CONTRACT || 'Not set (public group)'); - - const user = await PushAPI.initialize(walletClient, { - env: PUSH_ENV, - }); - - console.log('\nCreating chat group...'); - - // Build group options - const groupOptions: any = { - description: 'Ultimate Dominion game chat - Adventurer badge required', - image: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - members: [], - admins: [], - private: false, // Public visibility, but gated entry - }; - - // Add token gating rules if badge contract is provided - if (BADGE_CONTRACT) { - groupOptions.rules = { - entry: { - conditions: { - any: [ - { - type: 'PUSH', - category: 'ERC721', - subcategory: 'holder', - data: { - contract: `eip155:${CHAIN_ID}:${BADGE_CONTRACT}`, - comparison: '>=', - amount: 1, - }, - }, - ], - }, - }, - }; - console.log(' Token gating enabled for badge holders'); - } else { - console.log(' No token gating (public group)'); - } - - try { - const groupResponse = await user.chat.group.create('Ultimate Dominion', groupOptions); - - console.log('\n✅ Group created successfully!\n'); - console.log('Group Chat ID:', groupResponse.chatId); - console.log('\nUpdate GROUP_CHAT_ID in src/contexts/ChatContext.tsx:'); - console.log(`const GROUP_CHAT_ID = '${groupResponse.chatId}';`); - - if (BADGE_CONTRACT) { - console.log(`\nAlso update BADGE_CONTRACT_ADDRESS:`); - console.log(`const BADGE_CONTRACT_ADDRESS = '${BADGE_CONTRACT}';`); - } - } catch (error: any) { - console.error('\n❌ Failed to create group:', error.message); - if (error.message.includes('already exists')) { - console.log('\nTip: A group with this name may already exist. Try a different name.'); - } - } -} - -createTokenGatedGroup().catch(console.error); diff --git a/packages/client/scripts/validate-env.js b/packages/client/scripts/validate-env.js index 992dd0a2a..d66b980c3 100644 --- a/packages/client/scripts/validate-env.js +++ b/packages/client/scripts/validate-env.js @@ -17,7 +17,7 @@ const REQUIRED = [ 'VITE_GAME_LIVE', 'VITE_PRIVY_APP_ID', 'VITE_RELAYER_URL', - 'VITE_FUND_API_KEY', + 'VITE_WORLD_ADDRESS', 'VITE_INDEXER_API_URL', 'VITE_INDEXER_WS_URL', 'VITE_API_URL', diff --git a/packages/client/src/App.gridRows.ts b/packages/client/src/App.gridRows.ts new file mode 100644 index 000000000..703542138 --- /dev/null +++ b/packages/client/src/App.gridRows.ts @@ -0,0 +1 @@ +export const APP_GRID_TEMPLATE_ROWS = 'auto auto 1fr auto'; diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 1e9987bb0..e0f446158 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -6,12 +6,13 @@ import { useBreakpointValue, useDisclosure, } from '@chakra-ui/react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { GiScrollUnfurled } from 'react-icons/gi'; import { BrowserRouter as Router, useLocation } from 'react-router-dom'; import { BetaBanner } from './components/BetaBanner'; -import { WorldFeed } from './components/WorldFeed'; +import { BootScreen } from './components/BootScreen'; +import { ChatPanel } from './components/ChatPanel'; import { DiscordButton } from './components/DiscordButton'; import { FeedbackButton } from './components/FeedbackButton'; import { Footer } from './components/Footer'; @@ -35,30 +36,36 @@ import { QueueProvider } from './contexts/QueueContext'; import { SoundProvider } from './contexts/SoundContext'; import { useGasStation } from './hooks/useGasStation'; import { OnboardingStage, useOnboardingStage } from './hooks/useOnboardingStage'; -import AppRoutes, { CHARACTER_CREATION_PATH, HOME_PATH } from './Routes'; +import { useGameStore } from './lib/gameStore'; +import { APP_GRID_TEMPLATE_ROWS } from './App.gridRows'; +import AppRoutes, { + CHARACTER_CREATION_PATH, + GAME_BOARD_PATH, + HOME_PATH, +} from './Routes'; import { IS_CHAT_BOX_OPEN_KEY } from './utils/constants'; export const App = (): JSX.Element => { return ( - - - - - - - - - - + + + + + + + + + + + - ); }; @@ -77,6 +84,7 @@ const AppInner = (): JSX.Element => { isWalletDetailsModalOpen, onCloseWalletDetailsModal, onOpenWalletDetailsModal, + ready, } = useMUD(); const { isOpen: isFeedOpen, onOpen: onOpenFeed, unreadCount } = useChat(); const { character } = useCharacter(); @@ -85,6 +93,26 @@ const AppInner = (): JSX.Element => { isOpen: isGoldMerchantOpen, onClose: onCloseGoldMerchant, } = useGoldMerchant(); + const gameStoreHydrated = useGameStore((s) => s.hydrated); + + // Preload the GameBoard chunk in parallel with MUD setup + gameStore hydration. + // Without this, the AppInner gate can release (ready+synced+hydrated) while the + // lazy GameBoard chunk is still downloading, causing AppRoutes' inner Suspense + // to paint the orange shell + "Loading..." VStack before GameBoard renders. + // React.lazy's factory in Routes.tsx hits the same module URL, so once it's + // cached here, Suspense resolves synchronously and never fires. + const [gameBoardChunkLoaded, setGameBoardChunkLoaded] = useState(false); + useEffect(() => { + if (pathname !== GAME_BOARD_PATH) return; + if (gameBoardChunkLoaded) return; + let cancelled = false; + import('./pages/GameBoard').then(() => { + if (!cancelled) setGameBoardChunkLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [pathname, gameBoardChunkLoaded]); // Activate GasStation auto-swap hook useGasStation(); @@ -140,11 +168,29 @@ const AppInner = (): JSX.Element => { } }, [isDesktop, onOpenFeed, pathname]); + // Hold the dark boot screen through MUD setup, wallet sync, gameStore + // snapshot hydration, AND GameBoard lazy-chunk download. Dropping any one + // of these produces a visible flash on refresh: `ready`/`isSynced` cover + // MUD wallet init, `gameStoreHydrated` covers the network snapshot, and + // `gameBoardChunkLoaded` covers the Suspense window inside AppRoutes where + // the lazy chunk is still downloading. + if ( + pathname === GAME_BOARD_PATH && + (!ready || !isSynced || !gameStoreHydrated || !gameBoardChunkLoaded) + ) { + return ( + + ); + } + return ( @@ -205,7 +251,7 @@ const AppInner = (): JSX.Element => { right={2} zIndex={10} > - + )} diff --git a/packages/client/src/GameAppRoot.tsx b/packages/client/src/GameAppRoot.tsx new file mode 100644 index 000000000..3122c3e0d --- /dev/null +++ b/packages/client/src/GameAppRoot.tsx @@ -0,0 +1,82 @@ +import '@fontsource/cinzel/400.css'; +import '@fontsource/cinzel/500.css'; +import '@fontsource/cinzel/600.css'; +import '@fontsource/cinzel/700.css'; +import '@fontsource/cormorant-garamond/400.css'; +import '@fontsource/cormorant-garamond/500.css'; +import '@fontsource/cormorant-garamond/600.css'; +import '@fontsource/cormorant-garamond/700.css'; +import '@fontsource/fira-code/300.css'; +import '@fontsource/fira-code/400.css'; +import '@fontsource/fira-code/500.css'; +import '@fontsource/fira-code/600.css'; +import '@fontsource/fira-code/700.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/500.css'; +import '@fontsource/inter/600.css'; +import '@fontsource/inter/700.css'; +import { ChakraProvider } from '@chakra-ui/react'; +import { Global } from '@emotion/react'; +import { HelmetProvider } from 'react-helmet-async'; + +import { PrivyProvider, type PrivyClientConfig } from '@privy-io/react-auth'; + +import { App } from './App'; +import { AllowanceProvider } from './contexts/AllowanceContext'; +import { AuthProvider } from './contexts/AuthContext'; +import { CharacterProvider } from './contexts/CharacterContext'; +import { ItemsProvider } from './contexts/ItemsContext'; +import { MonstersProvider } from './contexts/MonstersContext'; +import { MUDProvider } from './contexts/MUDContext'; +import { OrdersProvider } from './contexts/OrdersContext'; +import { Web3Provider } from './contexts/Web3Provider'; +import { GameStoreProvider } from './lib/gameStore'; +import { base } from './lib/mud/supportedChains'; +import { globalStyles, theme } from './utils/theme'; + +const privyAppId = import.meta.env.VITE_PRIVY_APP_ID || ''; + +const privyConfig: PrivyClientConfig = { + loginMethods: ['google'], + appearance: { theme: 'dark' }, + embeddedWallets: { + createOnLogin: 'off', + requireUserPasswordOnCreate: false, + showWalletUIs: false, + }, + defaultChain: base as PrivyClientConfig['defaultChain'], + supportedChains: [base as NonNullable], +}; + +const setupPromise = import('./lib/mud/setup').then(({ setup }) => setup()); + +const GameAppRoot = (): JSX.Element => ( + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default GameAppRoot; diff --git a/packages/client/src/PlaceholderApp.tsx b/packages/client/src/PlaceholderApp.tsx index 9dec34419..ca37e9d4a 100644 --- a/packages/client/src/PlaceholderApp.tsx +++ b/packages/client/src/PlaceholderApp.tsx @@ -5,6 +5,7 @@ import { Box, Text, VStack } from '@chakra-ui/react'; import { Helmet } from 'react-helmet-async'; import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { BrowserRouter, Link as RouterLink, @@ -26,105 +27,98 @@ const ExternalRedirect = ({ to }: { to: string }) => { * The live game's Manifesto.tsx uses a light theme, so we re-implement * the content here to match the dark landing page aesthetic. */ -const DarkManifesto = (): JSX.Element => ( - - - Manifesto | Ultimate Dominion - - - { + const { t } = useTranslation('pages'); + return ( + + + {t('manifesto.metaTitle')} + + - Manifesto - - - - - You wake in a cave with no memory and no name. Everything after - that is yours. - - - This is a world built for years, not minutes. Progression is slow - because the journey is the point. Stories are earned, not skipped. - The lore isn't written for you — it's written by - what you do, who you fight, what you choose to protect, and what - you let burn. - - - Everything here is permanent. Your gold, your weapons, your scars - — they belong to you. Not to a server. Not to us. No one - can take them, alter them, or shut them off. You don't have - to take our word for it. You can prove it. - - You don't need to download anything. Open your browser. Step - into the dark. + {t('manifesto.title')} - + + {t('manifesto.opening')} + + + {t('manifesto.p1')} + + + {t('manifesto.p2')} + + + {t('manifesto.p3')} + + + {t('manifesto.closing')} + + + + - This is not a game you finish. It's a world that becomes - part of you. - + ← Back + - - - ← Back - - - -); + + ); +}; const PlaceholderPage = ({ title, diff --git a/packages/client/src/PlaceholderAppRoot.tsx b/packages/client/src/PlaceholderAppRoot.tsx new file mode 100644 index 000000000..858ca22fe --- /dev/null +++ b/packages/client/src/PlaceholderAppRoot.tsx @@ -0,0 +1,34 @@ +import '@fontsource/cinzel/400.css'; +import '@fontsource/cinzel/500.css'; +import '@fontsource/cinzel/600.css'; +import '@fontsource/cinzel/700.css'; +import '@fontsource/cormorant-garamond/400.css'; +import '@fontsource/cormorant-garamond/500.css'; +import '@fontsource/cormorant-garamond/600.css'; +import '@fontsource/cormorant-garamond/700.css'; +import '@fontsource/fira-code/300.css'; +import '@fontsource/fira-code/400.css'; +import '@fontsource/fira-code/500.css'; +import '@fontsource/fira-code/600.css'; +import '@fontsource/fira-code/700.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/500.css'; +import '@fontsource/inter/600.css'; +import '@fontsource/inter/700.css'; +import { ChakraProvider } from '@chakra-ui/react'; +import { Global } from '@emotion/react'; +import { HelmetProvider } from 'react-helmet-async'; + +import { PlaceholderApp } from './PlaceholderApp'; +import { globalStyles, theme } from './utils/theme'; + +const PlaceholderAppRoot = (): JSX.Element => ( + + + + + + +); + +export default PlaceholderAppRoot; diff --git a/packages/client/src/Routes.test.tsx b/packages/client/src/Routes.test.tsx new file mode 100644 index 000000000..5913f5546 --- /dev/null +++ b/packages/client/src/Routes.test.tsx @@ -0,0 +1,95 @@ +/** + * Tests for the AppRoutes Suspense fallback and the App-level Grid row + * template that broke routing-time loading screens on beta. + * + * Context for future readers: + * Before the fix, Header returned a React Fragment with two siblings — an + * amber `IS_BETA` strip (#C87A2A) and the nav Grid. Fragments flatten into + * their parent, so the App.tsx Grid saw 5 children instead of 4. Its + * `templateRows="auto 1fr auto"` then assigned 1fr to the orange BETA strip, + * which stretched to fill every viewport where the content row was smaller + * than the viewport (Suspense "Loading..." flashes, empty marketplace, etc.). + * The result was an orange-everything loading state on every route except + * /game-board hard refresh — because BootScreen's zIndex:9999 overlay hid it. + * + * The fix is structural: + * 1. Header returns a single wrapper (not a fragment) + * 2. App.tsx templateRows is "auto auto 1fr auto" so AppRoutes gets 1fr + * 3. RoutesFallback renders a dark in-place loader for all non-game-board + * routes (matches body #12100E — no orange app shell exposed) + */ +import { render, cleanup } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./lib/env', () => ({ + SHOW_Z2: true, + IS_BETA: true, + IS_PRODUCTION: false, +})); + +import { RoutesFallback } from './Routes'; +import { APP_GRID_TEMPLATE_ROWS } from './App.gridRows'; + +describe('RoutesFallback dark loader', () => { + afterEach(() => cleanup()); + + function renderFallbackAt(pathname: string) { + return render( + + + + } /> + + + , + ); + } + + it('renders a dark surface on /marketplace (no orange app shell)', () => { + const { container } = renderFallbackAt('/marketplace'); + // The wrapper Box is the first element rendered by the fallback. + const wrapper = container.querySelector('div[class]'); + expect(wrapper).toBeTruthy(); + // Chakra translates bg="#12100E" to an inline style var or class; we + // assert the loading text is present rather than poking at Chakra's + // class generation. The dark bg is pinned by the RoutesFallback source. + expect(container.textContent?.toLowerCase()).toContain('loading'); + }); + + it('renders a dark surface on /leaderboard (no orange app shell)', () => { + const { container } = renderFallbackAt('/leaderboard'); + expect(container.textContent?.toLowerCase()).toContain('loading'); + }); + + it('renders a dark surface on /character/:id (no orange app shell)', () => { + const { container } = renderFallbackAt('/characters/0xabc'); + expect(container.textContent?.toLowerCase()).toContain('loading'); + }); + + it('renders a dark surface on /guild (no orange app shell)', () => { + const { container } = renderFallbackAt('/guild'); + expect(container.textContent?.toLowerCase()).toContain('loading'); + }); + + it('renders BootScreen on /game-board (full dark overlay)', () => { + const { container } = renderFallbackAt('/game-board'); + // BootScreen renders its eyebrow copy; dark-loader path does not. + expect(container.textContent).toContain('Entering The Realm'); + }); +}); + +describe('App Grid row template', () => { + it('uses auto auto 1fr auto so content row gets flex, not header strip', () => { + // Static assertion: the string must have three `auto`s and exactly one + // `1fr`, with `1fr` in the third slot. Before the fix it was + // "auto 1fr auto", which gave Header's fragment-leaked orange strip + // the flex row. + expect(APP_GRID_TEMPLATE_ROWS).toBe('auto auto 1fr auto'); + const tokens = APP_GRID_TEMPLATE_ROWS.split(/\s+/); + expect(tokens).toHaveLength(4); + expect(tokens.filter(t => t === '1fr')).toHaveLength(1); + expect(tokens.indexOf('1fr')).toBe(2); + }); +}); diff --git a/packages/client/src/Routes.tsx b/packages/client/src/Routes.tsx index 29b17d052..db6bf79f6 100644 --- a/packages/client/src/Routes.tsx +++ b/packages/client/src/Routes.tsx @@ -1,6 +1,7 @@ -import { Text, VStack } from '@chakra-ui/react'; -import React, { Component, ReactNode, Suspense } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Box, Text, VStack } from '@chakra-ui/react'; +import React, { Component, ReactNode, Suspense, useEffect } from 'react'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import { BootScreen } from './components/BootScreen'; import { SHOW_Z2 } from './lib/env'; // Auto-reload on stale chunk after deploy. If a lazy import fails (old chunk @@ -72,6 +73,9 @@ const MarketplaceItem = lazyWithReload(() => const Shop = lazyWithReload(() => import('./pages/Shop').then(m => ({ default: m.Shop })), ); +const Respec = lazyWithReload(() => + import('./pages/Respec').then(m => ({ default: m.Respec })), +); const Welcome = lazyWithReload(() => import('./pages/Welcome').then(m => ({ default: m.Welcome })), ); @@ -99,6 +103,9 @@ const ClassPage = lazyWithReload(() => const Guild = lazyWithReload(() => import('./pages/Guild').then(m => ({ default: m.Guild })), ); +const PretextLab = lazyWithReload(() => + import('./pages/PretextLab').then(m => ({ default: m.PretextLab })), +); export const HOME_PATH = '/'; export const MANIFESTO_PATH = '/manifesto'; @@ -116,6 +123,8 @@ export const PRIVACY_PATH = '/privacy'; export const TERMS_PATH = '/terms'; export const FAQ_PATH = '/faq'; export const GUILD_PATH = '/guild'; +export const RESPEC_PATH = '/respec'; +export const PRETEXT_LAB_PATH = '/pretext-lab'; export const BLOG_URL = 'https://ultimatedominion.com/blog'; export const TAVERN_URL = 'https://tavern.ultimatedominion.com'; @@ -124,11 +133,38 @@ const ExternalRedirect = ({ to }: { to: string }) => { return null; }; -const RoutesFallback = () => ( - - Loading... - -); +// Suspense fallback for AppRoutes. /game-board gets the full-screen BootScreen +// overlay (position:fixed, zIndex 9999) so the lazy-chunk window is visually +// identical to the AppInner boot gate. Every other route gets a dark in-place +// loader that fills the AppRoutes grid cell — matching the body #12100E so no +// orange app-shell surface is ever exposed during Suspense. +export const RoutesFallback = () => { + const { pathname } = useLocation(); + if (pathname === GAME_BOARD_PATH) { + return ( + + ); + } + return ( + + + + Loading + + + + ); +}; const AppRoutes: React.FC = () => { return ( @@ -142,6 +178,8 @@ const AppRoutes: React.FC = () => { } /> } /> {SHOW_Z2 && } />} + {SHOW_Z2 && } />} + {SHOW_Z2 && } />} } /> } /> } /> diff --git a/packages/client/src/components/ActionsPanel.test.tsx b/packages/client/src/components/ActionsPanel.test.tsx index 35c1cb5b6..3e7384ab8 100644 --- a/packages/client/src/components/ActionsPanel.test.tsx +++ b/packages/client/src/components/ActionsPanel.test.tsx @@ -180,6 +180,7 @@ function setDefaults() { mapState = { isSpawned: true, monstersOnTile: [], + visibleMonstersOnTile: [], position: { x: 1, y: 1 }, }; } @@ -252,7 +253,7 @@ describe('ActionsPanel — Auto Adventure Inline Results', () => { render(); - expect(screen.getByText(/Auto Adventure/)).toBeTruthy(); + expect(screen.getByText('No monsters here. Try another tile.')).toBeTruthy(); expect(screen.queryByText('Defeated Dire Rat!')).toBeNull(); expect(screen.queryByText('Defeated by Dire Rat.')).toBeNull(); }); @@ -331,7 +332,7 @@ describe('ActionsPanel — Auto Adventure Inline Results', () => { expect(screen.getByTestId('item-equip-modal')).toBeTruthy(); }); - it('auto-adventure controls persist when results are showing', () => { + it('tile guidance persists when results are showing', () => { battleState.currentBattle = normalBattle; battleState.lastestBattleOutcome = winOutcome; @@ -339,8 +340,8 @@ describe('ActionsPanel — Auto Adventure Inline Results', () => { // Results should show expect(screen.getByText('Defeated Dire Rat!')).toBeTruthy(); - // Auto-adventure controls should ALSO be visible (additive, not replaced) - expect(screen.getByText(/Auto Adventure/)).toBeTruthy(); + // The idle guidance stays visible while inline results are appended below it. + expect(screen.getByText('No monsters here. Try another tile.')).toBeTruthy(); }); }); diff --git a/packages/client/src/components/ActionsPanel.tsx b/packages/client/src/components/ActionsPanel.tsx index 72f844cc5..dfca9df66 100644 --- a/packages/client/src/components/ActionsPanel.tsx +++ b/packages/client/src/components/ActionsPanel.tsx @@ -1,8 +1,10 @@ import { Box, Button, + Grid, HStack, Image, + Portal, Progress, Spinner, Stack, @@ -14,8 +16,14 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { zeroAddress, zeroHash } from 'viem'; +import { Trans, useTranslation } from 'react-i18next'; import SafeTypist from './SafeTypist'; +import { getBattleConsoleState } from './battleConsole'; +import { SHOW_Z2 } from '../lib/env'; +import { CombatTypewriter } from './pretext/game/CombatTypewriter'; +import { GameItemTooltip } from './pretext/game/GameItemTooltip'; +import { useCombatNarrative } from '../hooks/useCombatNarrative'; import { useBattle } from '../contexts/BattleContext'; import { useCharacter } from '../contexts/CharacterContext'; import { useItems } from '../contexts/ItemsContext'; @@ -29,7 +37,6 @@ import { BATTLE_OUTCOME_SEEN_KEY, SLOT_ORDER_KEY_PREFIX, STATUS_EFFECT_NAME_MAPPING, - STATUS_EFFECT_DESCRIPTION_MAPPING, } from '../utils/constants'; import { getItemImage } from '../utils/itemImages'; import { etherToFixedNumber, removeEmoji } from '../utils/helpers'; @@ -37,21 +44,10 @@ import { ConsumableQuickUse } from './ConsumableQuickUse'; import { ItemEquipModal } from './ItemEquipModal'; import { PotionSvg } from './SVGs/PotionSvg'; -export const MONSTER_MOVE_MAPPING: Record = { - '1': 'Razor Claws', // Dire Rat - '2': 'Elemental Burst', // Fungal Shaman - '3': 'Stone Fist', // Cavern Brute - '4': 'Elemental Burst', // Crystal Elemental - '5': 'Crushing Slam', // Ironhide Troll - '6': 'Venomous Bite', // Phase Spider - '7': 'Dark Magic', // Bonecaster - '8': 'Crushing Slam', // Rock Golem - '9': 'Shadow Strike', // Pale Stalker - '10': 'Elemental Burst', // Dusk Drake - '11': 'Basilisk Fangs', // Basilisk (boss) -}; - export const ActionsPanel = (): JSX.Element => { + const { t } = useTranslation('ui'); + const { t: te } = useTranslation('effects'); + const { t: tm } = useTranslation('monsters'); const { character, equippedArmor, equippedConsumables, equippedSpells, equippedWeapons, refreshCharacter } = useCharacter(); const { isSpawned, visibleMonstersOnTile, position } = useMap(); @@ -72,17 +68,18 @@ export const ActionsPanel = (): JSX.Element => { const { autoAdventureMode, isRefreshing, onToggleAutoAdventure } = useMovement(); const stage = useOnboardingStage(); - const { visibleOutcomes, pendingTurn } = useCombatPacing({ + const { visibleOutcomes, pendingTurn, isBattleResolutionPending } = useCombatPacing({ attackOutcomes, characterId: character?.id, isInBattle: !!currentBattle, }); // Display name prefixed with "Elite" for elite mobs + // (declared early so combatLogEntries can reference it) const opponentDisplayName = useMemo(() => { - if (!opponent) return 'a monster'; + if (!opponent) return t('battle.aMonster'); const isElite = 'isElite' in opponent && (opponent as Monster).isElite; - return isElite ? `Elite ${opponent.name}` : opponent.name; + return isElite ? t('battle.elitePrefix', { name: opponent.name }) : opponent.name; }, [opponent]); const { @@ -96,7 +93,9 @@ export const ActionsPanel = (): JSX.Element => { const [turnTimeLeft, setTurnTimeLeft] = useState(32); const [attackButtonFocus, setAttackButtonFocus] = useState(0); + const [hoveredTokenId, setHoveredTokenId] = useState(null); + const weaponGridRef = useRef(null); const parentDivRef = useRef(null); const attackButton1Ref = useRef(null); const attackButton2Ref = useRef(null); @@ -198,11 +197,16 @@ export const ActionsPanel = (): JSX.Element => { return () => window.removeEventListener('keydown', listener); }, [attackButtonFocus]); - const battleOver = useMemo( + const battleResolved = useMemo( () => currentBattle?.encounterId === lastestBattleOutcome?.encounterId, [currentBattle, lastestBattleOutcome], ); + const battleOver = useMemo( + () => battleResolved && !isBattleResolutionPending, + [battleResolved, isBattleResolutionPending], + ); + const userTurn = useMemo(() => { if (!(character && currentBattle)) return false; @@ -250,6 +254,7 @@ export const ActionsPanel = (): JSX.Element => { const canAttack = useMemo(() => { if (!currentBattle) return false; + if (battleResolved) return false; if (currentBattle.encounterType === EncounterType.PvE) { return true; @@ -264,7 +269,7 @@ export const ActionsPanel = (): JSX.Element => { } return false; - }, [currentBattle, userTurn, turnTimeLeft]); + }, [battleResolved, currentBattle, userTurn, turnTimeLeft]); const weaponsAndSpells = useMemo( () => [...equippedWeapons, ...equippedSpells], @@ -301,6 +306,19 @@ export const ActionsPanel = (): JSX.Element => { [spellTemplates, weaponTemplates], ); + const combatNarrative = useCombatNarrative({ + visibleOutcomes, + pendingTurn, + dotActions, + statusEffectActions, + characterId: character?.id, + opponentName: opponentDisplayName, + opponent: opponent && 'mobId' in opponent ? opponent as Monster : null, + encounterType: currentBattle?.encounterType, + spellAndWeaponTemplates, + combatConsumables, + }); + const STAT_LABELS: Record = { [StatsClasses.Strength]: 'STR', [StatsClasses.Agility]: 'AGI', @@ -347,6 +365,7 @@ export const ActionsPanel = (): JSX.Element => { const canFlee = useMemo(() => { if (!character) return false; if (!currentBattle) return false; + if (battleResolved) return false; const isAttacker = currentBattle.attackers.includes(character.id); @@ -359,7 +378,7 @@ export const ActionsPanel = (): JSX.Element => { } return false; - }, [character, currentBattle]); + }, [battleResolved, character, currentBattle]); const hasSmokeCover = useMemo(() => { if (!character) return false; @@ -376,6 +395,18 @@ export const ActionsPanel = (): JSX.Element => { return currentBattle.maxTurns === currentBattle.currentTurn; }, [currentBattle]); + const battleConsole = useMemo(() => { + if (!currentBattle || !opponent) return null; + + return getBattleConsoleState({ + encounterType: currentBattle.encounterType, + opponentDisplayName, + userTurn, + canAttack, + turnTimeLeft, + }); + }, [canAttack, currentBattle, opponent, opponentDisplayName, turnTimeLeft, userTurn]); + // Track the last known opponent name so auto-adventure results can capture it // even after the dead monster is pruned from allMonsters (opponent goes null). const lastOpponentNameRef = useRef(opponentDisplayName); @@ -453,27 +484,41 @@ export const ActionsPanel = (): JSX.Element => { {battleOver && currentBattle && !autoAdventureMode && ( + + Battle Complete + {battleDraw ? ( - The battle ended in a draw. + {t('battle.drawEnd')} ) : ( {lastestBattleOutcome?.winner === character?.id && @@ -482,41 +527,67 @@ export const ActionsPanel = (): JSX.Element => { : ''} {lastestBattleOutcome?.winner !== character?.id && lastestBattleOutcome?.playerFled - ? 'You fled!' + ? t('combat.youFled') : ''} {lastestBattleOutcome?.winner === character?.id && !lastestBattleOutcome?.playerFled - ? 'You won!' + ? t('combat.youWon') : ''} {lastestBattleOutcome?.winner !== character?.id && !lastestBattleOutcome?.playerFled - ? 'You died...' + ? t('combat.youDied') : ''} )} {lastestBattleOutcome && lastestBattleOutcome.winner === character?.id && !lastestBattleOutcome.playerFled && (lastestBattleOutcome.expDropped > 0n || lastestBattleOutcome.goldDropped > 0n) && ( - + {lastestBattleOutcome.expDropped > 0n && ( - + + +{lastestBattleOutcome.expDropped.toString()} XP - + + )} {lastestBattleOutcome.expDropped > 0n && lastestBattleOutcome.goldDropped > 0n && ( · )} {lastestBattleOutcome.goldDropped > 0n && ( - + + +{etherToFixedNumber(lastestBattleOutcome.goldDropped)} Gold - + + )} )} @@ -543,18 +614,19 @@ export const ActionsPanel = (): JSX.Element => { size="sm" onClick={onFleePvp} variant="outline" - color="#A0522D" - borderColor="#A0522D" - _hover={{ bg: 'rgba(160,82,45,0.15)' }} + color="#D89272" + borderColor="rgba(184,92,58,0.45)" + bg="rgba(184,92,58,0.10)" + _hover={{ bg: 'rgba(184,92,58,0.20)', borderColor: 'rgba(184,92,58,0.65)' }} > Flee {hasSmokeCover - ? 'Smoke Cloak active — flee without gold penalty!' + ? t('combat.smokeCloakActive') : currentBattle.encounterType === EncounterType.PvP - ? 'First turn only. Costs 25% carried gold.' - : 'First turn only.'} + ? t('combat.fleeFirstTurnCost') + : t('combat.fleeFirstTurn')} )} @@ -565,157 +637,338 @@ export const ActionsPanel = (): JSX.Element => { equippedSpellsAndWeapons.length !== 0 && opponent && ( - {currentBattle.encounterType === EncounterType.PvE && ( - - Choose your move! - - )} - - {currentBattle.encounterType === EncounterType.PvP && ( - <> - {userTurn && ( - - - Choose your move! - {' '} - You have {turnTimeLeft} seconds before your opponent can - attack. - - )} - {!userTurn && !canAttack && ( - - It is your opponent's turn. But you can attack in{' '} - {turnTimeLeft} seconds. - - )} - {!userTurn && canAttack && ( - - Your opponent took too long to make a move.{' '} - - You can now attack! + + + + {battleConsole.eyebrow} - + + {battleConsole.title} + + {battleConsole.detail && ( + + {battleConsole.detail} + + )} + + {battleConsole.badge && ( + + + {battleConsole.badge} + + + )} + + {currentBattle.encounterType === EncounterType.PvP && ( + )} - + )} - - {currentBattle.encounterType === EncounterType.PvP && ( - - )} - + + {SHOW_Z2 && isDesktop && hoveredTokenId && (() => { + const hovItem = orderedAttackItems.find(i => i.tokenId === hoveredTokenId); + if (!hovItem) return null; + const mu = weaponMatchups[hovItem.tokenId]; + const rect = weaponGridRef.current?.getBoundingClientRect(); + if (!rect) return null; + return ( + + + + + + ); + })()} + {actionItems.map((item, index) => { const icon = getItemImage(removeEmoji(item.name)); const matchupData = item.type === 'attack' ? weaponMatchups[item.tokenId] : undefined; const matchup = matchupData?.matchup; const statType = matchupData?.statType; + const accent = item.type === 'consumable' + ? { + bg: 'rgba(90,138,62,0.08)', + border: 'rgba(90,138,62,0.28)', + eyebrow: 'Consumable', + eyebrowColor: '#8FCB6C', + } + : matchup === 'strong' + ? { + bg: 'rgba(90,138,62,0.12)', + border: 'rgba(90,138,62,0.38)', + eyebrow: 'Advantage', + eyebrowColor: '#8FCB6C', + } + : matchup === 'weak' + ? { + bg: 'rgba(184,92,58,0.12)', + border: 'rgba(184,92,58,0.38)', + eyebrow: 'High Risk', + eyebrowColor: '#D89272', + } + : { + bg: 'rgba(255,255,255,0.03)', + border: 'rgba(120,108,92,0.3)', + eyebrow: 'Neutral', + eyebrowColor: '#B7AA95', + }; return ( ); })} - - - {isDesktop && actionItems.length > 0 && ( - - Use 1-{Math.min(actionItems.length, 4)} keys to act - - )} - {canFlee && ( - + + + + + {isDesktop && actionItems.length > 0 && ( + + Use 1-{Math.min(actionItems.length, 4)} keys to act + + )} + {(attackStatusMessage || currentBattle.encounterType === EncounterType.PvP) && ( + + {attackStatusMessage || ( + canAttack + ? 'The clock is yours. Take the opening.' + : 'Watch the timer and prepare the counter.' + )} + + )} + + {canFlee && ( - - )} + )} + )} @@ -726,11 +979,7 @@ export const ActionsPanel = (): JSX.Element => { stdTypingDelay={10} > - In order to begin battling, you must{' '} - - spawn - {' '} - your character. + }} /> )} @@ -740,10 +989,10 @@ export const ActionsPanel = (): JSX.Element => { {position.x === 0 && position.y === 0 - ? 'Move to a new tile to find monsters.' + ? t('combat.moveToFind') : visibleMonstersOnTile.length === 0 - ? 'No monsters here. Try another tile.' - : 'Click on a monster to battle.'} + ? t('combat.noMonstersHere') + : t('combat.clickToFight')} {/* Auto-adventure paused — hidden until re-enabled */} @@ -773,8 +1022,8 @@ export const ActionsPanel = (): JSX.Element => { {isCritical - ? 'You are close to death. Heal before your next fight.' - : 'Consider using a potion before continuing.'} + ? t('combat.closeToDeath') + : t('combat.considerPotion')} ); @@ -782,7 +1031,14 @@ export const ActionsPanel = (): JSX.Element => { return null; })()} - {!autoAdventureMode && opponent && + {SHOW_Z2 && !autoAdventureMode && opponent && combatNarrative && battleOver && ( + + )} + {!SHOW_Z2 && !autoAdventureMode && opponent && (() => { const seenDotTurns = new Set(); const logSize = { base: '2xs' as const, sm: 'xs' as const, lg: 'sm' as const }; @@ -794,7 +1050,7 @@ export const ActionsPanel = (): JSX.Element => { const itemName = currentBattle?.encounterType === EncounterType.PvE && attack.attackerId !== character?.id - ? MONSTER_MOVE_MAPPING[(opponent as Monster).mobId] ?? 'an item' + ? tm(`moves.${(opponent as Monster).mobId}`, { defaultValue: 'an item' }) : attackItem?.name ?? 'an item'; const possibleStatusEffectAttack = statusEffectActions.find( @@ -831,7 +1087,7 @@ export const ActionsPanel = (): JSX.Element => { stdTypingDelay={10} > - {isPlayer ? 'You' : opponentDisplayName} used{' '} + {isPlayer ? t('combat.you') : opponentDisplayName} {t('combat.used')}{' '} {consumable ? removeEmoji(consumable.name) : 'a potion'} @@ -939,7 +1195,7 @@ export const ActionsPanel = (): JSX.Element => { {isCrit && ( - Critical hit! + {t('battle.criticalHit')} )} {isPlayerAttack ? ( @@ -966,16 +1222,12 @@ export const ActionsPanel = (): JSX.Element => { {affectedText} - {STATUS_EFFECT_DESCRIPTION_MAPPING[ - possibleStatusEffectAttack.name - ] && ( + {te(`descriptions.${possibleStatusEffectAttack.name}`, { defaultValue: '' }) && ( - {STATUS_EFFECT_DESCRIPTION_MAPPING[ - possibleStatusEffectAttack.name - ]} + {te(`descriptions.${possibleStatusEffectAttack.name}`)} )} @@ -991,14 +1243,14 @@ export const ActionsPanel = (): JSX.Element => { ) : 'you'} . {effectNames[0] && - STATUS_EFFECT_DESCRIPTION_MAPPING[effectNames[0]] && ( + te(`descriptions.${effectNames[0]}`, { defaultValue: '' }) && ( {' '} - {effectNames[0]}.{' '} - {STATUS_EFFECT_DESCRIPTION_MAPPING[effectNames[0]]} + {te(`names.${effectNames[0]}`)}.{' '} + {te(`descriptions.${effectNames[0]}`)} )} @@ -1015,7 +1267,7 @@ export const ActionsPanel = (): JSX.Element => { .{' '} {effectNames[0] ? `${effectNames[0]} is already active.` - : 'It had no effect.'} + : t('combat.noEffect')} ); } else if (isPlayerAttack) { @@ -1088,7 +1340,7 @@ export const ActionsPanel = (): JSX.Element => { )} {attack.blocked && ( - {isPlayerAttack ? `${opponentDisplayName} blocked some damage.` : 'You blocked some damage.'} + {isPlayerAttack ? t('combat.blockedSome', { name: opponentDisplayName }) : t('combat.youBlockedSome')} )} {attack.spellDodged && ( diff --git a/packages/client/src/components/AdvancedClassModal.tsx b/packages/client/src/components/AdvancedClassModal.tsx index 32a8a4ee9..a80bce18f 100644 --- a/packages/client/src/components/AdvancedClassModal.tsx +++ b/packages/client/src/components/AdvancedClassModal.tsx @@ -10,6 +10,7 @@ import { VStack, } from '@chakra-ui/react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link as RouterLink } from 'react-router-dom'; import { ShareButton } from './ShareButton'; @@ -212,6 +213,27 @@ const detailSlide = keyframes` to { opacity: 1; transform: translateY(0); } `; +const sparkleFloat = keyframes` + 0% { opacity: 0; transform: translate(var(--sx), var(--sy)) scale(0); } + 20% { opacity: 1; transform: translate(calc(var(--sx) * 0.5), calc(var(--sy) * 0.5)) scale(1); } + 80% { opacity: 0.6; transform: translate(calc(var(--sx) * 0.1), calc(var(--sy) * 0.1)) scale(0.8); } + 100% { opacity: 0; transform: translate(0, 0) scale(0); } +`; + +const SPARKLE_PARTICLES = Array.from({ length: 18 }, (_, i) => { + const angle = (i / 18) * Math.PI * 2; + const radius = 40 + Math.random() * 50; + return { + id: i, + left: `${50 + Math.cos(angle) * radius}%`, + top: `${50 + Math.sin(angle) * radius}%`, + sx: `${Math.cos(angle) * (80 + Math.random() * 120)}px`, + sy: `${Math.sin(angle) * (80 + Math.random() * 120)}px`, + delay: `${0.1 + Math.random() * 0.8}s`, + size: `${2 + Math.random() * 3}px`, + }; +}); + /* ──────────────────────── Component ──────────────────────── */ type AdvancedClassModalProps = { @@ -227,6 +249,7 @@ export const AdvancedClassModal = ({ characterId, onClassSelected, }: AdvancedClassModalProps): JSX.Element | null => { + const { t } = useTranslation('ui'); const { systemCalls: { selectAdvancedClass }, } = useMUD(); @@ -314,6 +337,28 @@ export const AdvancedClassModal = ({ pointerEvents="none" /> + {/* Entrance sparkle particles */} + {!confirmedClass && ( + + {SPARKLE_PARTICLES.map((p) => ( + + ))} + + )} + {/* ── SELECTION VIEW ── */} {!confirmedClass && ( - Not now + {t('advancedClass.notNow')} @@ -352,7 +397,7 @@ export const AdvancedClassModal = ({ opacity={0} animation={`${titleReveal} 1s 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards`} > - Choose Your Path + {t('advancedClass.chooseYourPath')} {/* Shimmer divider */} @@ -379,7 +424,7 @@ export const AdvancedClassModal = ({ opacity={0} animation={`${subtitleFade} 0.6s 1s cubic-bezier(0.16, 1, 0.3, 1) forwards`} > - This decision is permanent. Choose wisely. + {t('advancedClass.permanent')} {/* Class Grid */} @@ -545,7 +590,7 @@ export const AdvancedClassModal = ({ color="#8A7E6A" _hover={{ color: selectedColor, textDecoration: 'underline' }} > - Learn more + {t('advancedClass.learnMore')} - Bonuses + {t('advancedClass.bonuses')} {selectedInfo.flatBonuses} @@ -579,7 +624,7 @@ export const AdvancedClassModal = ({ border="1px solid #3A3228" > - Multipliers + {t('advancedClass.multipliers')} {selectedInfo.multipliers} @@ -592,7 +637,7 @@ export const AdvancedClassModal = ({ border="1px solid #3A3228" > - Class Spell + {t('advancedClass.classSpell')} {selectedInfo.spell} @@ -614,7 +659,7 @@ export const AdvancedClassModal = ({ textTransform="uppercase" fontSize="sm" > - Become a {selectedInfo.name} + {t('advancedClass.become', { name: selectedInfo.name })} @@ -731,8 +776,7 @@ export const AdvancedClassModal = ({ animation={`${fadeUp} 0.8s 1.8s cubic-bezier(0.16, 1, 0.3, 1) forwards`} textShadow="0 1px 3px rgba(0,0,0,0.4)" > - You have walked the Dark Cave and survived.{'\n'} - Your path is chosen. The world will remember. + {t('advancedClass.chosenMessage')} {/* Actions */} @@ -746,10 +790,10 @@ export const AdvancedClassModal = ({ textTransform="uppercase" fontSize="sm" > - Continue + {t('common.continue')} = { adventurer: GiCrossedSwords, founder: GiLaurelsTrophy, + guild_founder: GiBlackFlag, zone_conqueror: GiCrownedSkull, zone_fragment: GiScrollQuill, peaks_pioneer: GiMountainRoad, @@ -73,14 +76,15 @@ export const BadgeIcons = ({ badges }: { badges: Badge[] }): JSX.Element | null * Shows all earned badges with labels and descriptions. */ export const BadgeShowcase = ({ badges }: { badges: Badge[] }): JSX.Element => { + const { t } = useTranslation('ui'); return ( - Badges + {t('badges.title')} {badges.length === 0 ? ( - No badges earned yet + {t('badges.noneEarned')} ) : ( diff --git a/packages/client/src/components/BattleOutcomeModal.test.tsx b/packages/client/src/components/BattleOutcomeModal.test.tsx index d3e5c3597..639007f79 100644 --- a/packages/client/src/components/BattleOutcomeModal.test.tsx +++ b/packages/client/src/components/BattleOutcomeModal.test.tsx @@ -15,6 +15,7 @@ let mockCurrentBattle: any = null; const mockRefreshCharacter = vi.fn().mockResolvedValue(undefined); const mockOnContinueToBattleOutcome = vi.fn(); +const mockPlaySfx = vi.fn(); // --- vi.mock declarations --- @@ -47,6 +48,15 @@ vi.mock('../contexts/ItemsContext', () => ({ }), })); +vi.mock('../contexts/SoundContext', () => ({ + useGameAudio: () => ({ + soundEnabled: true, + toggleSound: vi.fn(), + playSfx: mockPlaySfx, + duckMusic: vi.fn(), + }), +})); + vi.mock('../hooks/useToast', () => ({ useToast: () => ({ renderError: vi.fn(), @@ -88,12 +98,12 @@ vi.mock('./LevelUpBanner', () => ({ ), })); -vi.mock('./LevelingPanel', () => ({ - LevelingPanel: (props: any) => ( +vi.mock('./pretext/game/BattleMonsterAscii', () => ({ + BattleMonsterAscii: (props: any) => (
), })); @@ -150,7 +160,9 @@ function makeWinOutcome(overrides: Record = {}) { }; } -// MAX_LEVEL = 10 (from constants.ts). canLevel = false when Number(character.level) >= MAX_LEVEL. +// MAX_LEVEL = 20 (from constants.ts). BattleOutcomeModal no longer renders an +// inline LevelingPanel; it only shows a LevelUpBanner when the battle newly +// triggers eligibility or the character already leveled. // --- Tests --- @@ -164,6 +176,7 @@ describe('BattleOutcomeModal — max level behavior', () => { mockCurrentBattle = null; mockRefreshCharacter.mockClear(); mockOnContinueToBattleOutcome.mockClear(); + mockPlaySfx.mockClear(); localStorage.clear(); }); @@ -171,10 +184,10 @@ describe('BattleOutcomeModal — max level behavior', () => { cleanup(); }); - // --- Happy path: maxed character (level 10) --- + // --- Happy path: maxed character (level 20) --- - it('does NOT render LevelingPanel when character is at max level', () => { - mockCharacter = makeCharacter({ level: 10n, experience: 5000n }); + it('does NOT render inline leveling UI when character is at max level', () => { + mockCharacter = makeCharacter({ level: 20n, experience: 85000n }); const outcome = makeWinOutcome(); render( @@ -185,16 +198,15 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - // canLevel is false at max level -> no LevelingPanel expect(screen.queryByTestId('leveling-panel')).toBeNull(); }); it('does NOT render LevelUpBanner when character is at max level', () => { - mockCharacter = makeCharacter({ level: 10n, experience: 5000n }); + mockCharacter = makeCharacter({ level: 20n, experience: 85000n }); // Provide next level data so the XP comparison would pass if not maxed - const nextLevelKey = '0x' + 'a'.padStart(64, '0'); - mockGameValues[nextLevelKey] = { experience: 4000n }; + const nextLevelKey = '0x' + '14'.padStart(64, '0'); + mockGameValues[nextLevelKey] = { experience: 80000n }; const outcome = makeWinOutcome(); @@ -211,7 +223,7 @@ describe('BattleOutcomeModal — max level behavior', () => { }); it('shows victory text but no leveling UI when maxed and winning', () => { - mockCharacter = makeCharacter({ level: 10n, experience: 5000n }); + mockCharacter = makeCharacter({ level: 20n, experience: 85000n }); const outcome = makeWinOutcome(); render( @@ -222,15 +234,81 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - expect(screen.getByText('Victory!')).toBeDefined(); - expect(screen.getByText(/You defeated/)).toBeDefined(); + expect(screen.getByText('battle.victory')).toBeDefined(); + expect(screen.getByText('battle.youDefeated')).toBeDefined(); expect(screen.queryByTestId('leveling-panel')).toBeNull(); expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); + it('renders ascii monster art in the PvE victory modal', () => { + mockCharacter = makeCharacter({ level: 5n, experience: 5000n }); + mockOpponent = { name: 'Skeleton' }; + mockCurrentBattle = { encounterType: 1, currentTurn: 1n, maxTurns: 2n }; + + render( + , + ); + + const portrait = screen.getByTestId('battle-monster-ascii'); + expect(portrait.getAttribute('data-name')).toBe('Skeleton'); + expect(portrait.getAttribute('data-defeated')).toBe('false'); + }); + + it('plays battle-win SFX for elite PvE victories only', () => { + mockCharacter = makeCharacter({ level: 5n, experience: 5000n }); + mockOpponent = { name: 'Skeleton', isElite: true }; + mockCurrentBattle = { encounterType: 1, currentTurn: 1n, maxTurns: 2n }; + + render( + , + ); + + expect(mockPlaySfx).toHaveBeenCalledWith('battle-win'); + }); + + it('does not play battle-win SFX for normal PvE victories', () => { + mockCharacter = makeCharacter({ level: 5n, experience: 5000n }); + mockOpponent = { name: 'Skeleton', isElite: false }; + mockCurrentBattle = { encounterType: 1, currentTurn: 1n, maxTurns: 2n }; + + render( + , + ); + + expect(mockPlaySfx).not.toHaveBeenCalledWith('battle-win'); + }); + + it('plays battle-win SFX for boss PvE victories', () => { + mockCharacter = makeCharacter({ level: 5n, experience: 5000n }); + mockOpponent = { name: 'Skeleton King', hasBossAI: true }; + mockCurrentBattle = { encounterType: 1, currentTurn: 1n, maxTurns: 2n }; + + render( + , + ); + + expect(mockPlaySfx).toHaveBeenCalledWith('battle-win'); + }); + // --- Happy path: non-maxed character with enough XP --- - it('renders LevelingPanel with canLevel=true when not maxed with enough XP', () => { + it('does NOT render inline leveling UI when already eligible before the battle', () => { mockCharacter = makeCharacter({ level: 5n, experience: 300n }); const nextLevelKey = '0x' + '5'.padStart(64, '0'); @@ -246,10 +324,8 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - const levelingPanel = screen.getByTestId('leveling-panel'); - expect(levelingPanel).toBeDefined(); - expect(levelingPanel.getAttribute('data-can-level')).toBe('true'); - expect(levelingPanel.getAttribute('data-compact')).toBe('true'); + expect(screen.queryByTestId('leveling-panel')).toBeNull(); + expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); it('renders LevelUpBanner when this battle pushed XP over threshold', () => { @@ -282,7 +358,7 @@ describe('BattleOutcomeModal — max level behavior', () => { // justBecameEligible: initialExperience(150) < 200 && 250 >= 200 = true expect(screen.getByTestId('level-up-banner')).toBeDefined(); - expect(screen.getByTestId('leveling-panel')).toBeDefined(); + expect(screen.queryByTestId('leveling-panel')).toBeNull(); }); // --- Edge cases --- @@ -326,7 +402,7 @@ describe('BattleOutcomeModal — max level behavior', () => { expect(screen.queryByTestId('leveling-panel')).toBeNull(); }); - it('level 8 with enough XP shows leveling UI (boundary test)', () => { + it('level 8 with enough XP does not render inline leveling UI (boundary test)', () => { mockCharacter = makeCharacter({ level: 8n, experience: 5000n }); const nextLevelKey = '0x' + '8'.padStart(64, '0'); @@ -342,12 +418,11 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - const levelingPanel = screen.getByTestId('leveling-panel'); - expect(levelingPanel).toBeDefined(); - expect(levelingPanel.getAttribute('data-can-level')).toBe('true'); + expect(screen.queryByTestId('leveling-panel')).toBeNull(); + expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); - it('level 9 is NOT maxed — shows leveling UI with enough XP', () => { + it('level 9 is NOT maxed — but still uses the full level-up flow', () => { mockCharacter = makeCharacter({ level: 9n, experience: 5000n }); const nextLevelKey = '0x' + '9'.padStart(64, '0'); @@ -363,10 +438,8 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - // level 9 < MAX_LEVEL(10) -> canLevel = true (XP 5000 >= threshold 4000) - const levelingPanel = screen.getByTestId('leveling-panel'); - expect(levelingPanel).toBeDefined(); - expect(levelingPanel.getAttribute('data-can-level')).toBe('true'); + expect(screen.queryByTestId('leveling-panel')).toBeNull(); + expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); it('renders empty Box when character is null', () => { @@ -401,7 +474,7 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - expect(screen.getByText('Defeat...')).toBeDefined(); + expect(screen.getByText('battle.defeat')).toBeDefined(); // LevelingPanel and LevelUpBanner gated by winner === character.id expect(screen.queryByTestId('leveling-panel')).toBeNull(); expect(screen.queryByTestId('level-up-banner')).toBeNull(); @@ -427,7 +500,7 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - expect(screen.getByText('Draw...')).toBeDefined(); + expect(screen.getByText('battle.draw')).toBeDefined(); expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); @@ -447,7 +520,7 @@ describe('BattleOutcomeModal — max level behavior', () => { />, ); - expect(screen.getByText('Defeat...')).toBeDefined(); + expect(screen.getByText('battle.defeat')).toBeDefined(); expect(screen.queryByTestId('leveling-panel')).toBeNull(); expect(screen.queryByTestId('level-up-banner')).toBeNull(); }); diff --git a/packages/client/src/components/BattleOutcomeModal.tsx b/packages/client/src/components/BattleOutcomeModal.tsx index fb1fe6a05..2c64d6601 100644 --- a/packages/client/src/components/BattleOutcomeModal.tsx +++ b/packages/client/src/components/BattleOutcomeModal.tsx @@ -19,9 +19,12 @@ import { useGameValue, encodeUint256Key, toBigInt } from '../lib/gameStore'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { zeroAddress, zeroHash } from 'viem'; +import { useTranslation } from 'react-i18next'; + import { useBattle } from '../contexts/BattleContext'; import { useCharacter } from '../contexts/CharacterContext'; import { useItems } from '../contexts/ItemsContext'; +import { useGameAudio } from '../contexts/SoundContext'; import { useToast } from '../hooks/useToast'; import { BATTLE_OUTCOME_SEEN_KEY, MAX_LEVEL } from '../utils/constants'; import { etherToFixedNumber } from '../utils/helpers'; @@ -32,22 +35,20 @@ import { EncounterType, type Monster, Rarity, - RARITY_NAMES, + RARITY_I18N_KEYS, type Spell, type Weapon, } from '../utils/types'; -import { OnboardingStage, useOnboardingStage } from '../hooks/useOnboardingStage'; - -import { ItemCard } from './ItemCard'; import { getItemImage } from '../utils/itemImages'; import { getMonsterImage } from '../utils/monsterImages'; import { LootReveal } from './LootReveal'; import { ShareButton } from './ShareButton'; import { ItemEquipModal } from './ItemEquipModal'; -import { LevelUpBanner } from './LevelUpBanner'; -import { LevelingPanel } from './LevelingPanel'; import { PolygonalCard } from './PolygonalCard'; +import { LevelUpBanner } from './LevelUpBanner'; +import { BattleMonsterAscii } from './pretext/game/BattleMonsterAscii'; +import { MONSTER_TEMPLATES_REDUX } from './pretext/game/monsterTemplatesRedux'; type BattleOutcomeModalProps = { isOpen: boolean; @@ -60,7 +61,9 @@ export const BattleOutcomeModal: React.FC = ({ onClose, battleOutcome, }): JSX.Element => { + const { t } = useTranslation('ui'); const { renderError } = useToast(); + const { playSfx } = useGameAudio(); const { armorTemplates, consumableTemplates, spellTemplates, weaponTemplates } = useItems(); const { character, @@ -71,13 +74,18 @@ export const BattleOutcomeModal: React.FC = ({ refreshCharacter, } = useCharacter(); const { currentBattle, onContinueToBattleOutcome, opponent } = useBattle(); + const { goldDropped, playerFled, winner } = battleOutcome; const opponentDisplayName = useMemo(() => { - if (!opponent) return 'a monster'; + if (!opponent) return t('battle.aMonster'); const isElite = 'isElite' in opponent && (opponent as Monster).isElite; - return isElite ? `Elite ${opponent.name}` : opponent.name; + return isElite ? t('battle.elitePrefix', { name: opponent.name }) : opponent.name; + }, [opponent, t]); + + const hasAsciiPortrait = useMemo(() => { + if (!opponent) return false; + return MONSTER_TEMPLATES_REDUX.some(template => template.name === opponent.name); }, [opponent]); - const stage = useOnboardingStage(); const [armor, setArmor] = useState([]); const [consumables, setConsumables] = useState([]); @@ -89,6 +97,7 @@ export const BattleOutcomeModal: React.FC = ({ >(null); const [initialLevel] = useState(() => character?.level); const [initialExperience] = useState(() => character?.experience); + const battleWinSfxRef = useRef(null); const hasLeveledUp = useMemo( () => @@ -197,7 +206,7 @@ export const BattleOutcomeModal: React.FC = ({ setWeapons(_weapons); } catch (e) { renderError( - (e as Error)?.message ?? 'Failed to fetch looted items.', + (e as Error)?.message ?? t('battle.fetchFailed'), e, ); } finally { @@ -250,12 +259,36 @@ export const BattleOutcomeModal: React.FC = ({ return currentBattle.maxTurns === currentBattle.currentTurn; }, [currentBattle]); + useEffect(() => { + if (!isOpen || !character || !opponent || !currentBattle) return; + if (battleWinSfxRef.current === battleOutcome.encounterId) return; + if (winner !== character.id || battleDraw || playerFled) return; + if (currentBattle.encounterType !== EncounterType.PvE) return; + if (hasLeveledUp || justBecameEligible) return; + + const monster = opponent as Monster; + if (!monster.isElite && !monster.hasBossAI) return; + + battleWinSfxRef.current = battleOutcome.encounterId; + playSfx('battle-win'); + }, [ + battleDraw, + battleOutcome.encounterId, + character, + currentBattle, + hasLeveledUp, + isOpen, + justBecameEligible, + opponent, + playSfx, + playerFled, + winner, + ]); + if (!character) { return ; } - const { expDropped, goldDropped, playerFled, winner } = battleOutcome; - if (playerFled) { return ( @@ -263,37 +296,37 @@ export const BattleOutcomeModal: React.FC = ({ - {winner === character.id ? 'Victory!' : 'Defeat...'} + {winner === character.id ? t('battle.victory') : t('battle.defeat')} {winner === character.id - ? `${opponentDisplayName} fled!` - : `You fled from ${opponentDisplayName}.`} + ? t('battle.monsterFled', { name: opponentDisplayName }) + : t('battle.youFled', { name: opponentDisplayName })} {winner === character.id ? ( - You earned{' '} + {t('battle.earnedGold')}{' '} {etherToFixedNumber(goldDropped)} {' '} - Gold. + {t('battle.gold')} ) : ( - Fleeing cost you{' '} + {t('battle.fleeingCost')}{' '} {etherToFixedNumber(goldDropped)} {' '} - Carried Gold. + {t('battle.carriedGold')} )} - + @@ -308,57 +341,78 @@ export const BattleOutcomeModal: React.FC = ({ {battleDraw - ? 'Draw...' + ? t('battle.draw') : winner === character.id - ? 'Victory!' - : 'Defeat...'} + ? t('battle.victory') + : t('battle.defeat')} {battleDraw ? ( - The battle ended in a draw! You both fled the battlefield. + {t('battle.drawMessage')} ) : ( {winner === character.id - ? `You defeated ${opponentDisplayName}!` - : `You were killed by ${opponentDisplayName}.`} + ? t('battle.youDefeated', { name: opponentDisplayName }) + : t('battle.youWereKilled', { name: opponentDisplayName })} - {opponent && currentBattle?.encounterType !== EncounterType.PvP && getMonsterImage(opponent.name) && ( - {opponent.name} + {opponent && currentBattle?.encounterType !== EncounterType.PvP && ( + hasAsciiPortrait ? ( + + + + ) : getMonsterImage(opponent.name) ? ( + {opponent.name} + ) : null )} {winner !== character.id && goldDropped > 0n && ( - You lost{' '} + {t('battle.lostGold')}{' '} {etherToFixedNumber(goldDropped)} {' '} - Carried Gold. + {t('battle.carriedGold')} )} {winner !== character.id && ( - When you die, your health is restored, but you are forced to - respawn at the Town Square. + {t('battle.deathMessage')} )} {winner === character.id && !battleDraw && ( = ({ )} {winner === character.id && sortedLoot.length > 0 && (sortedLoot[0].rarity ?? 0) >= Rarity.Uncommon && ( = ({ )} - + diff --git a/packages/client/src/components/BetaBanner.tsx b/packages/client/src/components/BetaBanner.tsx index f5886519b..95c2bd32d 100644 --- a/packages/client/src/components/BetaBanner.tsx +++ b/packages/client/src/components/BetaBanner.tsx @@ -1,9 +1,11 @@ import { Box, CloseButton, HStack, Link, Text } from '@chakra-ui/react'; import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; const DISMISSED_KEY = 'ud_beta_banner_dismissed'; export const BetaBanner = (): JSX.Element | null => { + const { t } = useTranslation('ui'); const [dismissed, setDismissed] = useState( () => sessionStorage.getItem(DISMISSED_KEY) === '1', ); @@ -31,7 +33,7 @@ export const BetaBanner = (): JSX.Element | null => { fontWeight="bold" textAlign="center" > - Early Access — Things may break. When in doubt, refresh.{' '} + {t('betaBanner.message')}{' '} { textDecoration="underline" _hover={{ color: '#fff' }} > - Report issues + {t('betaBanner.reportIssues')} { + it('shows BootScreen on /game-board when MUD is not ready', () => { + expect(shouldShowBootScreen(GAME_BOARD_PATH, false, false, false, false)).toBe(true); + }); + + it('shows BootScreen on /game-board when ready but not synced', () => { + // Refresh window where setupPromise has resolved (ready=true) but the + // wallet/burner path has not completed (isSynced=false). + expect(shouldShowBootScreen(GAME_BOARD_PATH, true, false, false, false)).toBe(true); + }); + + it('shows BootScreen on /game-board when synced but setup not ready', () => { + // Defensive: an impossible-in-practice combination today, but the gate + // must still block if any signal is false. Pins the OR semantics. + expect(shouldShowBootScreen(GAME_BOARD_PATH, false, true, true, true)).toBe(true); + }); + + it('shows BootScreen on /game-board when ready+synced but gameStore NOT hydrated', () => { + // MUD wallet path is complete so the old gate would release, but the + // network snapshot has not yet hydrated Zustand — GameBoard would paint + // empty/partial and snap-down once character data arrives. + expect(shouldShowBootScreen(GAME_BOARD_PATH, true, true, false, true)).toBe(true); + }); + + it('shows BootScreen on /game-board when everything is ready but GameBoard chunk NOT loaded', () => { + // The exact window this signal covers: MUD is up, gameStore has hydrated, + // but the lazy-loaded GameBoard module is still downloading. Without this + // gate, AppRoutes would render its Suspense fallback (the orange shell + + // "Loading..." VStack) between gate release and GameBoard paint. + expect(shouldShowBootScreen(GAME_BOARD_PATH, true, true, true, false)).toBe(true); + }); + + it('releases to normal render on /game-board only when ALL four signals are true', () => { + expect(shouldShowBootScreen(GAME_BOARD_PATH, true, true, true, true)).toBe(false); + }); + + it('does NOT gate Welcome page even when nothing is ready', () => { + // Welcome (HOME_PATH) must paint immediately — it's the logged-out + // landing and never needs game state. + expect(shouldShowBootScreen(HOME_PATH, false, false, false, false)).toBe(false); + }); + + it('does NOT gate Marketplace/other routes while loading', () => { + // Scope is intentionally narrow to /game-board. Other authenticated + // routes still have their own AppRoutes Suspense fallback. + expect(shouldShowBootScreen('/marketplace', false, false, false, false)).toBe(false); + expect(shouldShowBootScreen('/leaderboard', false, false, false, false)).toBe(false); + expect(shouldShowBootScreen('/privacy', false, false, false, false)).toBe(false); + }); + + it('does NOT gate Welcome even if game is fully ready', () => { + expect(shouldShowBootScreen(HOME_PATH, true, true, true, true)).toBe(false); + }); + + it('matches GAME_BOARD_PATH exactly, not as prefix', () => { + // If we ever add /game-board/foo, we want the gate to still decide per + // full pathname — this test documents the current exact-match behavior. + expect(shouldShowBootScreen('/game-board/foo', false, false, false, false)).toBe(false); + }); +}); diff --git a/packages/client/src/components/BootScreen.tsx b/packages/client/src/components/BootScreen.tsx new file mode 100644 index 000000000..3ad7448dd --- /dev/null +++ b/packages/client/src/components/BootScreen.tsx @@ -0,0 +1,77 @@ +/** + * Full-page dark boot screen used for both the initial React.lazy Suspense + * fallback (see index.tsx) and the in-app game-ready gate (see App.tsx). + * + * Keeping a single component means the transition from "React is loading the + * AppRoot chunk" → "AppRoot mounted but MUD setup / wallet / sync still + * pending" is visually seamless — no flash to the orange app shell with a + * footer "Loading..." text while game data hydrates. + */ + +export const BootScreen = ({ + body, + eyebrow, +}: { + body: string; + eyebrow: string; +}): JSX.Element => ( +
+
+
+ {eyebrow} +
+
+ Ultimate Dominion +
+
+ {body} +
+
+
+); + +export default BootScreen; diff --git a/packages/client/src/components/CaptchaGate.tsx b/packages/client/src/components/CaptchaGate.tsx index 416fc0df8..8c2192f38 100644 --- a/packages/client/src/components/CaptchaGate.tsx +++ b/packages/client/src/components/CaptchaGate.tsx @@ -1,5 +1,6 @@ import { Box, Button, Text, VStack } from '@chakra-ui/react'; import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Turnstile } from '@marsidev/react-turnstile'; const SITE_KEY = (import.meta.env.VITE_TURNSTILE_SITE_KEY || '1x00000000000000000000AA').trim(); // test key fallback @@ -10,6 +11,7 @@ type CaptchaGateProps = { }; export const CaptchaGate = ({ onVerified, isLoading }: CaptchaGateProps): JSX.Element => { + const { t } = useTranslation('ui'); const [token, setToken] = useState(null); const [error, setError] = useState(false); const [expired, setExpired] = useState(false); @@ -50,24 +52,24 @@ export const CaptchaGate = ({ onVerified, isLoading }: CaptchaGateProps): JSX.El {error && ( - Verification failed. Please try again. + {t('captcha.failed')} )} {expired && !error && ( - Verification expired. Please verify again. + {t('captcha.expired')} )} ); diff --git a/packages/client/src/components/CaveReactionOverlay.tsx b/packages/client/src/components/CaveReactionOverlay.tsx new file mode 100644 index 000000000..6f3909874 --- /dev/null +++ b/packages/client/src/components/CaveReactionOverlay.tsx @@ -0,0 +1,128 @@ +import { Box, keyframes } from '@chakra-ui/react'; +import { useEffect, useState } from 'react'; + +/* ──────────────────────── Keyframes ──────────────────────── */ + +const screenShake = keyframes` + 0% { transform: translate(0, 0); } + 10% { transform: translate(-2px, 1px); } + 20% { transform: translate(2px, -1px); } + 30% { transform: translate(-1px, -2px); } + 40% { transform: translate(1px, 2px); } + 50% { transform: translate(-2px, -1px); } + 60% { transform: translate(2px, 1px); } + 70% { transform: translate(-1px, 2px); } + 80% { transform: translate(1px, -1px); } + 90% { transform: translate(-2px, 1px); } + 100% { transform: translate(0, 0); } +`; + +const crackWiden = keyframes` + 0% { width: 2px; height: 2px; opacity: 0; } + 15% { width: 4px; height: 4px; opacity: 0.6; } + 40% { width: 120px; height: 3px; opacity: 1; } + 70% { width: 200px; height: 4px; opacity: 1; } + 100% { width: 280px; height: 6px; opacity: 0.8; } +`; + +const crackGlow = keyframes` + 0% { box-shadow: 0 0 4px rgba(200, 122, 42, 0.4); background: rgba(200, 122, 42, 0.6); } + 50% { box-shadow: 0 0 30px rgba(180, 198, 212, 0.8), 0 0 60px rgba(180, 198, 212, 0.3); background: rgba(180, 198, 212, 0.9); } + 100% { box-shadow: 0 0 40px rgba(180, 198, 212, 0.6), 0 0 80px rgba(180, 198, 212, 0.2); background: rgba(180, 198, 212, 0.7); } +`; + +const lightRaySpread = keyframes` + 0% { opacity: 0; transform: translateX(-50%) scaleX(0); } + 30% { opacity: 0.3; transform: translateX(-50%) scaleX(0.3); } + 100% { opacity: 0.6; transform: translateX(-50%) scaleX(1); } +`; + +const overlayFadeOut = keyframes` + 0% { opacity: 1; } + 100% { opacity: 0; } +`; + +const overlayFadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +`; + +/* ──────────────────────── Component ──────────────────────── */ + +type CaveReactionOverlayProps = { + onComplete: () => void; +}; + +export const CaveReactionOverlay = ({ + onComplete, +}: CaveReactionOverlayProps): JSX.Element => { + const [phase, setPhase] = useState<'shake' | 'crack' | 'fade'>('shake'); + + useEffect(() => { + const timers = [ + setTimeout(() => setPhase('crack'), 800), + setTimeout(() => setPhase('fade'), 2800), + setTimeout(onComplete, 3500), + ]; + return () => timers.forEach(clearTimeout); + }, [onComplete]); + + return ( + + {/* Dark overlay */} + + + {/* Crack of light at top-center */} + + + {/* Light rays spreading down from crack */} + {phase !== 'shake' && ( + + )} + + ); +}; diff --git a/packages/client/src/components/CharacterInspectOverlay.test.tsx b/packages/client/src/components/CharacterInspectOverlay.test.tsx new file mode 100644 index 000000000..b8a5aebb7 --- /dev/null +++ b/packages/client/src/components/CharacterInspectOverlay.test.tsx @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; + +// Polyfill matchMedia for happy-dom (Chakra's Show component needs it) +beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('max-width'), // render mobile layout (below lg) + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +import { CharacterInspectOverlay } from './CharacterInspectOverlay'; +import { ItemType, Race, Rarity, StatsClasses, PowerSource, ArmorType, AdvancedClass } from '../utils/types'; +import type { Character, Armor, Weapon, Spell, Consumable } from '../utils/types'; + +// Mock CharacterViewer — Three.js doesn't work in happy-dom +vi.mock('./pretext/game/CharacterViewer', () => ({ + CharacterViewer: ({ autoReveal }: { autoReveal?: boolean }) => ( +
+ ), +})); + +// Mock ItemEquipModal + ItemConsumeModal +vi.mock('./ItemEquipModal', () => ({ + ItemEquipModal: () =>
, +})); +vi.mock('./ItemConsumeModal', () => ({ + ItemConsumeModal: () =>
, +})); + +// Mock EquippedLoadout slot components +vi.mock('./EquippedLoadout', () => ({ + FilledSlot: ({ item, size }: { item: { name: string }; size?: string }) => ( +
{item.name}
+ ), + EmptySlot: ({ label, size }: { label?: string; size?: string }) => ( +
{label}
+ ), +})); + +const baseStats = { + agility: BigInt(10), + currentHp: BigInt(50), + entityClass: StatsClasses.Strength, + experience: BigInt(0), + intelligence: BigInt(5), + level: BigInt(3), + maxHp: BigInt(50), + strength: BigInt(15), + race: Race.Human, + powerSource: PowerSource.None, + startingArmor: ArmorType.Leather, + advancedClass: AdvancedClass.None, + hasSelectedAdvancedClass: false, +}; + +// We need to add the ArmorType and AdvancedClass to our mock — check if they exist +const mockCharacter: Character = { + ...baseStats, + baseStats, + externalGoldBalance: BigInt(100), + id: '0x01', + inBattle: false, + isSpawned: true, + locked: false, + owner: '0xabc', + position: { zoneId: 1, x: 5, y: 5 }, + pvpCooldownTimer: BigInt(0), + tokenId: '1', + worldStatusEffects: [], + name: 'TestHero', + description: '', + image: '', +} as Character; + +const mockWeapon: Weapon = { + name: 'Iron Axe', + description: 'A sturdy axe', + image: '', + itemType: ItemType.Weapon, + rarity: Rarity.Uncommon, + minLevel: BigInt(1), + tokenId: '100', + balance: 1, + itemId: '100', + price: BigInt(0), + minDamage: BigInt(5), + maxDamage: BigInt(10), + strModifier: BigInt(3), + agiModifier: BigInt(0), + intModifier: BigInt(0), + hpModifier: BigInt(0), + effects: [], + statRestrictions: { minStrength: BigInt(5), minAgility: BigInt(0), minIntelligence: BigInt(0) }, +} as unknown as Weapon; + +const mockArmor: Armor = { + name: 'Leather Vest', + description: 'Basic armor', + image: '', + itemType: ItemType.Armor, + rarity: Rarity.Common, + minLevel: BigInt(1), + tokenId: '200', + balance: 1, + itemId: '200', + price: BigInt(0), + armorModifier: BigInt(2), + strModifier: BigInt(1), + agiModifier: BigInt(0), + intModifier: BigInt(0), + hpModifier: BigInt(5), + statRestrictions: { minStrength: BigInt(0), minAgility: BigInt(0), minIntelligence: BigInt(0) }, +} as unknown as Armor; + +function renderOverlay(props: Partial> = {}) { + return render( + + + , + ); +} + +describe('CharacterInspectOverlay', () => { + it('renders character name and level', () => { + renderOverlay(); + expect(screen.getByText('TestHero')).toBeDefined(); + expect(screen.getByText('Level 3')).toBeDefined(); + }); + + it('renders CharacterViewer with autoReveal', () => { + renderOverlay(); + const viewer = screen.getByTestId('character-viewer'); + expect(viewer.dataset.autoReveal).toBe('true'); + }); + + it('renders empty slots when nothing equipped', () => { + renderOverlay(); + // Should have empty armor slot + 4 empty action slots = 5 total empty slots + const emptySlots = screen.getAllByTestId(/^empty-slot/); + expect(emptySlots.length).toBeGreaterThanOrEqual(5); + }); + + it('renders filled slot for equipped weapon', () => { + renderOverlay({ equippedWeapons: [mockWeapon] }); + expect(screen.getAllByTestId('filled-slot-Iron Axe').length).toBeGreaterThanOrEqual(1); + }); + + it('renders filled slot for equipped armor', () => { + renderOverlay({ equippedArmor: [mockArmor] }); + expect(screen.getAllByTestId('filled-slot-Leather Vest').length).toBeGreaterThanOrEqual(1); + }); + + it('shows Battle Readiness label', () => { + renderOverlay(); + const labels = screen.getAllByText(/Battle Readiness/); + expect(labels.length).toBeGreaterThanOrEqual(1); + }); + + it('shows combat rating number', () => { + renderOverlay(); + // Base: STR 15 + AGI 10 + INT 5 + ARM 0 = 30 + // The number renders inside a heading-style text + const ratingElements = screen.getAllByText('30'); + expect(ratingElements.length).toBeGreaterThanOrEqual(1); + }); + + it('includes equipment bonuses in combat rating', () => { + renderOverlay({ equippedWeapons: [mockWeapon], equippedArmor: [mockArmor] }); + // Base: 15+10+5 = 30, Weapon: STR+3, Armor: ARM+2 + STR+1 + // Total: 30 + 3 + 2 + 1 = 36 + const ratingElements = screen.getAllByText('36'); + expect(ratingElements.length).toBeGreaterThanOrEqual(1); + }); + + it('shows stat bonuses when equipment is present', () => { + renderOverlay({ equippedWeapons: [mockWeapon] }); + // Weapon gives STR+3 + const bonusElements = screen.getAllByText('+3'); + expect(bonusElements.length).toBeGreaterThanOrEqual(1); + }); + + it('renders close button', () => { + renderOverlay(); + // getAllByLabelText because Chakra Modal may add its own close button + const closeButtons = screen.getAllByLabelText('Close'); + expect(closeButtons.length).toBeGreaterThanOrEqual(1); + }); + + it('uses larger slot size for inspection', () => { + renderOverlay({ equippedWeapons: [mockWeapon] }); + // Desktop layout uses 64px slots + const filledSlot = screen.getAllByTestId('filled-slot-Iron Axe')[0]; + // The mock passes size as data attribute + expect(filledSlot.dataset.size).toBeDefined(); + }); +}); diff --git a/packages/client/src/components/CharacterInspectOverlay.tsx b/packages/client/src/components/CharacterInspectOverlay.tsx new file mode 100644 index 000000000..d7b2e145e --- /dev/null +++ b/packages/client/src/components/CharacterInspectOverlay.tsx @@ -0,0 +1,369 @@ +/** + * CharacterInspectOverlay — Full-screen WoW-style character inspection. + * + * Mobile: vertical stack with horizontal slot tray. + * Desktop: three-column paper doll (slots | character | slots). + * Slides up from bottom. Character auto-rotates on open. + */ + +import { + Box, + Center, + Grid, + HStack, + IconButton, + Modal, + ModalContent, + ModalOverlay, + Show, + Text, + VStack, +} from '@chakra-ui/react'; +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; +import { IoClose } from 'react-icons/io5'; + +import { EmptySlot, FilledSlot, type SlotItem } from './EquippedLoadout'; +import { ItemEquipModal } from './ItemEquipModal'; +import { ItemConsumeModal } from './ItemConsumeModal'; +import { getRarityColor } from '../utils/rarityHelpers'; +import { + type Armor, + type Character, + type Consumable, + ItemType, + type Spell, + type Weapon, +} from '../utils/types'; +import type { EquippedItemSlot } from './pretext/game/CharacterViewer'; + +const CharacterViewer = lazy(() => + import('./pretext/game/CharacterViewer').then(m => ({ default: m.CharacterViewer })), +); + +// ── Constants ───────────────────────────────────────────────────────── + +const INSPECT_SLOT_SIZE = '64px'; + +// ── Props ───────────────────────────────────────────────────────────── + +export interface CharacterInspectOverlayProps { + isOpen: boolean; + onClose: () => void; + character: Character; + equippedArmor: Armor[]; + equippedWeapons: Weapon[]; + equippedSpells: Spell[]; + equippedConsumables: Consumable[]; +} + +// ── Stats strip ──────────────────────────────────────────────────────── + +function StatChip({ label, base, bonus }: { label: string; base: number; bonus: number }) { + return ( + + + {label} + + + + {base} + + {bonus !== 0 && ( + 0 ? '#5A8A3E' : '#B83A2A'}> + {bonus > 0 ? '+' : ''}{bonus} + + )} + + + ); +} + +function StatsStrip({ character, equippedArmor, equippedWeapons, equippedSpells, equippedConsumables }: { + character: Character; + equippedArmor: Armor[]; + equippedWeapons: Weapon[]; + equippedSpells: Spell[]; + equippedConsumables: Consumable[]; +}) { + const baseStr = Number(character.baseStats?.strength ?? character.strength ?? 0); + const baseAgi = Number(character.baseStats?.agility ?? character.agility ?? 0); + const baseInt = Number(character.baseStats?.intelligence ?? character.intelligence ?? 0); + const baseHp = Number(character.baseStats?.maxHp ?? character.maxHp ?? 0); + + // Sum equipment bonuses + const allEquipped = [...equippedArmor, ...equippedWeapons, ...equippedSpells, ...equippedConsumables] as SlotItem[]; + let strBonus = 0, agiBonus = 0, intBonus = 0, hpBonus = 0, armBonus = 0; + for (const item of allEquipped) { + if ('strModifier' in item) strBonus += Number(item.strModifier ?? 0); + if ('agiModifier' in item) agiBonus += Number(item.agiModifier ?? 0); + if ('intModifier' in item) intBonus += Number(item.intModifier ?? 0); + if ('hpModifier' in item) hpBonus += Number(item.hpModifier ?? 0); + if ('armorModifier' in item) armBonus += Number(item.armorModifier ?? 0); + } + + const combatRating = (baseStr + strBonus) + (baseAgi + agiBonus) + (baseInt + intBonus) + armBonus; + + return ( + + + + Battle Readiness + + + {combatRating} + + + + + + + + + + + ); +} + +// ── Slot column (desktop) ────────────────────────────────────────────── + +function SlotBox({ + item, + label, + onClickItem, +}: { + item: SlotItem | null; + label: string; + onClickItem: (item: SlotItem) => void; +}) { + return ( + + {item ? ( + onClickItem(item)} + isInBattle={false} + size={INSPECT_SLOT_SIZE} + /> + ) : ( + + )} + + {label} + + + ); +} + +// ── Main component ───────────────────────────────────────────────────── + +export function CharacterInspectOverlay({ + isOpen, + onClose, + character, + equippedArmor, + equippedWeapons, + equippedSpells, + equippedConsumables, +}: CharacterInspectOverlayProps) { + const [selectedItem, setSelectedItem] = useState(null); + + const handleItemClick = useCallback((item: SlotItem) => { + setSelectedItem(item); + }, []); + + const handleCloseItemModal = useCallback(() => { + setSelectedItem(null); + }, []); + + // Build equipment items for CharacterViewer bone attachment + const viewerEquipment: EquippedItemSlot[] = useMemo(() => { + const items: EquippedItemSlot[] = []; + if (equippedWeapons[0]) items.push({ name: equippedWeapons[0].name, socket: 'hand_R.socket' }); + if (equippedWeapons[1]) items.push({ name: equippedWeapons[1].name, socket: 'hand_L.socket' }); + if (equippedArmor[0]) items.push({ name: equippedArmor[0].name, socket: 'chest.socket' }); + return items; + }, [equippedWeapons, equippedArmor]); + + // Action slots (weapons + spells + consumables) + const actionSlots = useMemo( + () => [...equippedWeapons, ...equippedSpells, ...equippedConsumables] as SlotItem[], + [equippedWeapons, equippedSpells, equippedConsumables], + ); + + const isConsumable = selectedItem?.itemType === ItemType.Consumable; + + return ( + <> + + + + {/* Close button */} + } + position="absolute" + top={4} + right={4} + zIndex={10} + variant="dark" + size="sm" + onClick={onClose} + /> + + {/* Character name header */} + + + {character.name} + + + Level {Number(character.level)} + + + + {/* Desktop: 3-column paper doll */} + + + {/* Left: Armor */} + + + + + {/* Center: Character */} + Loading...}> + + + + {/* Right: Weapons/Spells/Consumables */} + + {[0, 1, 2, 3].map(i => ( + + ))} + + + + + {/* Mobile: stacked layout */} + + + {/* Character viewer */} + Loading...}> + + + + {/* Horizontal slot tray */} + + {/* Armor slot */} + + {equippedArmor[0] ? ( + handleItemClick(equippedArmor[0])} + isInBattle={false} + size="52px" + /> + ) : ( + + )} + ARM + + + {/* Divider */} + + + {/* Action slots */} + {[0, 1, 2, 3].map(i => ( + + {actionSlots[i] ? ( + handleItemClick(actionSlots[i])} + isInBattle={false} + size="52px" + /> + ) : ( + + )} + {i + 1} + + ))} + + + + + {/* Stats strip — shared between layouts */} + + + + + + + {/* Item modal — reuse existing equip/consume flows */} + {selectedItem && !isConsumable && ( + + )} + {selectedItem && isConsumable && ( + + )} + + ); +} diff --git a/packages/client/src/components/ChatBox.tsx b/packages/client/src/components/ChatBox.tsx deleted file mode 100644 index 5ab37db2b..000000000 --- a/packages/client/src/components/ChatBox.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import { - Box, - Button, - CloseButton, - Heading, - HStack, - ScaleFade, - Text, - Textarea, - Tooltip, - VStack, -} from '@chakra-ui/react'; -import { useCallback, useEffect, useRef } from 'react'; -import { CiCircleCheck } from 'react-icons/ci'; -import { IoIosSend, IoMdInformationCircleOutline } from 'react-icons/io'; -import { FaMedal } from 'react-icons/fa'; -import { Link } from 'react-router-dom'; - -import { useChat } from '../contexts/ChatContext'; -import { useMap } from '../contexts/MapContext'; -import { shortenAddress } from '../utils/helpers'; -import { CLASS_COLORS } from '../utils/types'; - -import { PolygonalCard } from './PolygonalCard'; - -type ChatBoxProps = { inline?: boolean }; - -export const ChatBox: React.FC = ({ inline = false }) => { - const { allCharacters } = useMap(); - const { - chatUser, - hasBadge, - isCheckingBadge, - isGroupMember, - isJoiningGroupChat, - isLoggedIn, - isLoggingIn, - isSending, - isOpen: isChatBoxOpen, - messages, - newMessage, - onClose: onCloseChatBox, - onJoinGroupChat, - onLogin, - onSendMessage, - onSetNewMessage, - onSetMessageInputFocus, - } = useChat(); - - // Badge gating — disabled for beta, re-enable by setting VITE_BADGE_CONTRACT_ADDRESS - const badgeGatingEnabled = false; // TODO: restore after beta: !!import.meta.env.VITE_BADGE_CONTRACT_ADDRESS - const canAccessChat = !badgeGatingEnabled || hasBadge; - - const textareaRef = useRef(null); - const messagesEndRef = useRef(null); - - const adjustTextareaHeight = useCallback(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; - textareaRef.current.style.maxHeight = '200px'; - } - }, []); - - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, []); - - useEffect(() => { - adjustTextareaHeight(); - }, [adjustTextareaHeight, newMessage]); - - useEffect(() => { - if (isLoggedIn && isGroupMember && chatUser) { - scrollToBottom(); - } - }, [chatUser, isGroupMember, isLoggedIn, messages, scrollToBottom]); - - const isVisible = inline || isChatBoxOpen; - - const content = ( - - - - Chat - {hasBadge && ( - - - - - - )} - - - - - {!inline && } - - - {/* Badge gating message */} - {badgeGatingEnabled && !canAccessChat && !isCheckingBadge && ( - - - Chat Locked - - - Reach level 3 to unlock global chat and earn your Adventurer badge! - - - Keep adventuring and defeating monsters to level up. - - - )} - {/* Checking badge status */} - {badgeGatingEnabled && isCheckingBadge && ( - - - Checking chat access... - - - )} - {/* Login/Join flow - only show if badge gating passes */} - {canAccessChat && (isLoggingIn || !isGroupMember) && ( - - - Ultimate Dominion's chat is public and permanent. Do not - share personal information or sensitive data. - - {isLoggedIn ? ( - - ) : ( - - )} - - )} - {canAccessChat && isLoggedIn && isGroupMember && chatUser && ( - - {messages.map((message, index) => { - const isUser = message.from === chatUser.account; - const messageCharacter = allCharacters.find( - character => - character.owner.toLowerCase() === - message.from.toLowerCase(), - ); - - // Only show timestamp if it's been more than 30 minutes since the last message - const prevMessage = messages[index - 1]; - const showTimestamp = - !prevMessage || - new Date(message.timestamp).getTime() - - new Date(prevMessage.timestamp).getTime() > - 1000 * 60 * 30; - - // Announcement cards (JSX messages like rare drops, marketplace sales) - if (message.jsx) { - return ( - - {showTimestamp && ( - - —{' '} - {new Date(message.timestamp).toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - })}{' '} - — - - )} - - {message.jsx} - - - ); - } - - const nameColor = messageCharacter - ? CLASS_COLORS[messageCharacter.entityClass] ?? '#E8DCC8' - : '#E8DCC8'; - - return ( - - {showTimestamp && ( - - —{' '} - {new Date(message.timestamp).toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - })}{' '} - — - - )} - - - {!isUser && messageCharacter && ( - - {messageCharacter.name} - - )} - {!isUser && !messageCharacter && ( - - {shortenAddress(message.from)} - - )} - - {message.delivered && isUser && ( - - - - )} - - - {message.message} - - - - - - - ); - })} - - - )} - - {canAccessChat && isLoggedIn && isGroupMember && chatUser && isVisible && ( - -