diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..15dad95 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Injected by the Neon integration on Vercel. Do not commit real values. +DATABASE_URL= +DATABASE_URL_UNPOOLED= +# Optional. Defaults to https://testnet-rpc.boing.network +RPC_URL=https://testnet-rpc.boing.network +# Optional. When empty, writes are accepted only from the site's own origin. +APP_ORIGIN= diff --git a/.gitignore b/.gitignore index 5027797..af5db79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ node_modules/ dist/ +api/ .wrangler/ +.data/ .dev.vars .env .env.* !.env.example +.vercel/ *.log -coverage/ \ No newline at end of file +coverage/ diff --git a/README.md b/README.md index 8fcb924..bdda972 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **A little bounce. A buddy for life.** A small, Boing-native virtual pet game: hatch a creature, say hello, feed it, and meet everyone on the public pasture. -Testnet only (6913). This is an MVP, not an NFT marketplace, financial product, or airdrop promise. No contracts, consensus changes, custody keys, or scheduled jobs. Nothing is deployed automatically. +Testnet only (6913). This is an MVP, not an NFT marketplace, financial product, or airdrop promise. No contracts, consensus changes, custody keys, or scheduled jobs. The public site is https://boing-pets.vercel.app. Pushing does not publish a new Vercel deployment until the GitHub project is connected. ## Run locally @@ -33,6 +33,7 @@ Open **http://localhost:5177** (use this exact origin, not 127.0.0.1). Vite prox 4. **Daily hello** signs a server-generated, origin/account/chain/action/nonce/expiry-bound message. The server verifies Ed25519 over BLAKE3(UTF-8 message), matching current Express. No transaction fee. 5. **Feed** approves another tiny self-transfer and restores the belly. Feeding is capped at once per UTC day to prevent accidental repeat fees. Each action type is once/day; either care action counts for the streak, never twice on the same day. 6. The public pasture shows buddies, streaks, belly, joy and status. Yours is highlighted. Browse without connecting. Use Refresh to recompute time-dependent state. +7. The shared yard is a 3D view of those buddies. The first player can deploy the Boing reference NFT kennel, then each wallet mints one pet NFT. Staking checks that the wallet still holds that NFT. Bonding 0.01 testnet BOING is a real protocol bond. Kibble accrues from care, buildings, the staked NFT, and the bond, and is spent on a pen, snack stand, or toy cart. Belly loses 25 points/day since hatch or last feed; joy loses 20/day since any care. After two days without feeding, a buddy is scruffy; after four, a stray. A hello cheers up a stray but only a feed restores its belly. No permanent death or deletion. Streaks count consecutive UTC calendar days; yesterday's streak remains visible until today is missed. This is a calendar-day ritual, not a rolling 24-hour timer. @@ -42,9 +43,9 @@ Belly loses 25 points/day since hatch or last feed; joy loses 20/day since any c - **Wallet:** published `@boing-network/boing-sdk` **0.5.40**, native `boing_requestAccounts`, `boing_sendTransaction`, `boing_signMessage`; 32-byte Ed25519 account ids, not EVM addresses. Extension account/chain changes disconnect; chain is checked before actions. The web fallback is pinned to testnet. - **On-chain hatch/feed:** Boing's native Transfer payload has **no memo field**. Rather than invent an unsupported memo or deploy a new program, v1 uses an exact challenge-specific self-transfer of approximately **0.001 BOING** (less than 0.001001). The amount encodes a random per-action marker; Express displays the human-readable intent, which is **UI-only, not a signed memo**. Principal stays in the same wallet; only the network fee is spent. Users still need balance for amount plus fee. There is no treasury/sink recipient and no app fee. - **Verification:** Worker checks the RPC's chain id, successful receipt, post-challenge block height, exact block receipt/id/index, transaction sender, self-recipient, and exact challenge amount. It does not trust a browser-supplied success flag or arbitrary transaction hash. Failed/unknown transactions never grant care. Pending ids are saved locally so users can retry confirmation without resending. Challenges expire after one hour; confirm within that window. If a transaction failed or its challenge expired, Forget pending record clears only the local recovery record (it does not cancel or refund any transaction). -- **Public state:** one small Worker + D1 with unique wallet, transaction, challenge, and `(owner, action, UTC day)` constraints. D1 batches atomically write hatch and its event. Event-sourced state avoids lost-update races between feed/check-in. Repeated confirmation is idempotent; a hash cannot hatch/feed twice. +- **Public state:** unique wallet, transaction, challenge, and `(owner, action, UTC day)` constraints. Hatch and its event commit in one transaction. Event-sourced state avoids lost-update races between feed/check-in. Repeated confirmation is idempotent; a hash cannot hatch/feed twice. Local development still uses the Worker and D1. Vercel uses Neon Postgres. - **No cron:** status/decay/streaks derive lazily from timestamps on reads. No added Cloudflare trigger. Unconfirmed expired challenges are cleaned on challenge issuance. Confirmed challenge records remain as an audit trail. Gallery is paginated (24 pets/page). -- **Trust model:** Pets is a centralized off-chain care/index service backed by genuine testnet transactions, **not** a fully on-chain pet contract or transferable NFT. The pasture consists of submitted, verified claims; it is not an autonomous chain-wide scanner. No API means no pasture/care writes. The RPC is trusted for canonical inclusion; a malicious RPC or database operator is outside the v1 trust boundary. Event history is local until an operator provisions a shared D1 deployment. No anti-Sybil guarantee; per-wallet challenge throttling is not a substitute for public edge rate limiting. +- **Trust model:** Pets is a centralized off-chain care/index service backed by genuine testnet transactions, **not** a fully on-chain pet contract or transferable NFT. The pasture consists of submitted, verified claims; it is not an autonomous chain-wide scanner. No API means no pasture/care writes. The RPC is trusted for canonical inclusion; a malicious RPC or database operator is outside the v1 trust boundary. The Vercel app stores one shared pasture in Neon Postgres. A local Worker checkout keeps its own D1 file. No anti-Sybil guarantee; per-wallet challenge throttling is not a substitute for public edge rate limiting. ## API @@ -53,13 +54,15 @@ Belly loses 25 points/day since hatch or last feed; joy loses 20/day since any c - `POST /api/challenge` `{owner,action}` → one-hour challenge, exact signature message or native transaction request. - `POST /api/confirm` `{id,signature}` for check-in, or `{id,tx_hash}` for hatch/feed. -Writes require the configured `APP_ORIGIN` and JSON bodies (4 KiB maximum), signatures or chain proofs. No private keys, sessions, cookies, client secrets, or wallet seed imports. Challenges are not authentication by themselves. Before public deployment, add edge/IP abuse controls (including for reads and wallet-rotation attacks), audit economics/security, test retention/backfill behavior, and monitor D1 read usage. +Writes require the page's own origin and JSON bodies (4 KiB maximum), signatures or chain proofs. No private keys, sessions, cookies, client secrets, or wallet seed imports. Visitors connect a Boing wallet; the app does not use Cursor sign-in. Challenges are not authentication by themselves. Before a widely shared launch, add edge/IP abuse controls (including for reads and wallet-rotation attacks), audit economics/security, and test retention/backfill behavior. -## Deployment is deliberately not set up +## Vercel -`wrangler.jsonc` is a local-first, unused-until-approved Worker + static assets configuration, with a **placeholder D1 id**, no routes, `workers_dev: false`, preview URLs disabled, and **no cron**. CI only bundles with `--dry-run`; it does not deploy or require secrets. Never deploy the placeholder config. +The public app is a Vite client plus one Node.js function on Vercel Fluid Compute. `api/[...path].ts` serves the same care API. The pasture is Neon Postgres in `iad1`, provisioned by the Vercel Marketplace integration and reached with `@neondatabase/serverless`. `DATABASE_URL_UNPOOLED` is preferred so the driver is not pooled twice. The function migrates on startup. Hunger, joy, and streaks are still derived on read. There is no cron. -When Nico approves a shared preview: create a dedicated D1 database, put its non-secret id in the config, set `APP_ORIGIN` to that preview's exact origin, run migrations on that database, configure static assets and appropriate limits, then separately approve deployment. Production/custom domain/mainnet are not configured. Only change `RPC_URL` to a trusted **testnet 6913** endpoint; the API rejects another chain. +`RPC_URL` defaults to `https://testnet-rpc.boing.network` (chain 6913). Leave `APP_ORIGIN` unset so each deployment origin is taken from the request. Visitors connect a Boing wallet. The project is `nico-builds/boing-pets`. + +`wrangler.jsonc` remains a local-only Worker configuration: placeholder D1 id, no routes, `workers_dev: false`, preview URLs disabled, and no cron. CI dry-runs that bundle and does not deploy it. Never deploy the placeholder config. To run the Node server on this machine without Neon: `PETS_DB=sqlite npm start` after `npm run build`. ## Planned Passport quests (documentation only) @@ -70,12 +73,12 @@ No changes to boing.network Passport or airdrop eligibility are made here. A fut ## Deferred -DEX-swap forage on boing.finance; live finance-indexer integration; Passport wiring; mainnet; naming/trading/transferring pets; NFTs or a dedicated pet VM program; permanent chain-wide indexing/backfill; production hardening/rate limiting; custom domain and deployment. Pet naming is intentionally not user-generated yet, avoiding moderation and HTML injection concerns. +DEX-swap forage on boing.finance; live finance-indexer integration; Passport wiring; mainnet; naming/trading/transferring pets; NFTs or a dedicated pet VM program; permanent chain-wide indexing/backfill; production hardening/rate limiting. Pet naming is intentionally not user-generated yet, avoiding moderation and HTML injection concerns. Next projects: [boing.wall](https://github.com/Boing-Network/boing.wall), [boing.kudos](https://github.com/Boing-Network/boing.kudos), [boing.names](https://github.com/Boing-Network/boing.names). Their implementations follow Pets. ## Verification notes -Local lint, separate browser/Worker typechecks, build, 24 deterministic tests, local D1 migration and Worker dry-run are included. API tests use real SQLite transactions and a mocked testnet RPC; they never send chain transactions. The live RPC probe from the Windows machine failed with a TLS connection error during initial setup, so a real Express hatch/feed round trip still needs Nico's testnet-wallet approval and a reachable testnet RPC. Nothing was sent from a wallet by the implementation process. +Local lint, separate browser/Worker typechecks, build, deterministic tests, local D1 migration and Worker dry-run are included. API tests use SQLite and in-process Postgres (PGlite) with a mocked testnet RPC; they never send chain transactions. Read-only checks against `https://testnet-rpc.boing.network` returned chain 6913 from this environment. A real Express hatch/feed still needs Nico's testnet wallet. Nothing was sent from a wallet. Worker binding types are generated from config: run `npm run types:generate` after config changes. Observability is configured for a future approved deployment; production hardening remains deferred. diff --git a/migrations/0002_yard.sql b/migrations/0002_yard.sql new file mode 100644 index 0000000..3a226bb --- /dev/null +++ b/migrations/0002_yard.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS kennel ( + id INTEGER PRIMARY KEY CHECK (id = 1), + contract TEXT NOT NULL UNIQUE, + deployer TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS yard_challenges ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + action TEXT NOT NULL, + message TEXT NOT NULL, + amount TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + min_height INTEGER NOT NULL, + detail TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS pet_nfts ( + owner TEXT PRIMARY KEY, + token_id TEXT NOT NULL, + contract TEXT NOT NULL, + mint_tx TEXT NOT NULL UNIQUE, + staked INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS stalls ( + owner TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('pen', 'stand', 'toy')), + created_at INTEGER NOT NULL, + PRIMARY KEY (owner, kind) +); +CREATE TABLE IF NOT EXISTS yards ( + owner TEXT PRIMARY KEY, + kibble INTEGER NOT NULL, + settled_at INTEGER NOT NULL, + bonded INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS yard_events ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + action TEXT NOT NULL, + tx_hash TEXT, + created_at INTEGER NOT NULL, + day INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS skills ( + owner TEXT NOT NULL, + skill TEXT NOT NULL CHECK (skill IN ('forage', 'greet', 'play')), + points INTEGER NOT NULL, + last_day INTEGER NOT NULL, + PRIMARY KEY (owner, skill) +); +CREATE TABLE IF NOT EXISTS wardrobe ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + item TEXT NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS equipped ( + owner TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('head', 'neck', 'body', 'back', 'feet')), + piece TEXT NOT NULL, + PRIMARY KEY (owner, slot) +); +CREATE TABLE IF NOT EXISTS presence ( + owner TEXT PRIMARY KEY, + seen_at INTEGER NOT NULL, + x REAL, + z REAL, + pass TEXT, + pass_until INTEGER +); diff --git a/package-lock.json b/package-lock.json index f955606..f98aa19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,18 @@ "version": "0.1.0", "dependencies": { "@boing-network/boing-sdk": "0.5.40", + "@neondatabase/serverless": "^1.2.0", "@noble/ed25519": "^2.2.3", - "@noble/hashes": "^1.8.0" + "@noble/hashes": "^1.8.0", + "three": "^0.187.0" }, "devDependencies": { "@cloudflare/workers-types": "5.20261106.1", + "@electric-sql/pglite": "^0.5.15", "@eslint/js": "^9.39.0", "@types/node": "^24.19.2", + "@types/three": "^0.187.0", + "esbuild": "^0.28.2", "eslint": "^9.39.0", "prettier": "^3.9.10", "typescript": "~5.9.3", @@ -183,6 +188,20 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz", + "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -964,9 +983,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -984,9 +1000,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1004,9 +1017,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1024,9 +1034,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1044,9 +1051,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1064,9 +1068,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1084,9 +1085,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1104,9 +1102,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1124,9 +1119,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1150,9 +1142,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1176,9 +1165,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1202,9 +1188,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1228,9 +1211,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1254,9 +1234,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1280,9 +1257,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1306,9 +1280,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1456,6 +1427,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.2.0.tgz", + "integrity": "sha512-tnaqgixUengZQNd9ZrS3ytQfamJzLKY8QxgqWQTNHHtB+KrFQWk77JTbbpg4RD+J3qE4eO2lVytvGDX5Kj6FdQ==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@noble/ed25519": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", @@ -1639,9 +1619,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1659,9 +1636,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1679,9 +1653,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1699,9 +1670,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1719,9 +1687,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1739,9 +1704,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1829,6 +1791,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1871,6 +1840,35 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.187.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.187.0.tgz", + "integrity": "sha512-v+vKNarUtk4n2QRZ4+CN5ygMM0ARUjXjIQl0R/unMUfmStFeVqGbgqarbrpoGTXRZoulSH16ePrVJVxPJEbMUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.72.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", @@ -2704,6 +2702,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3094,9 +3099,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3118,9 +3120,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3142,9 +3141,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3166,9 +3162,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3257,6 +3250,13 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/miniflare": { "version": "5.20261104.0-beta", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261104.0-beta.tgz", @@ -3690,6 +3690,12 @@ "node": ">=8" } }, + "node_modules/three": { + "version": "0.187.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz", + "integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", diff --git a/package.json b/package.json index 273ea25..7cc587f 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ "dev": "vite --host 127.0.0.1", "dev:api": "wrangler dev --local --port 8787", "db:local": "wrangler d1 migrations apply pets --local", - "build": "npm run typecheck \u0026\u0026 vite build", - "typecheck": "tsc -p tsconfig.json --noEmit \u0026\u0026 tsc -p tsconfig.worker.json --noEmit", + "build": "npm run typecheck && vite build && npm run build:function && npm run build:server", + "build:function": "esbuild server/vercel-handler.ts --bundle --platform=node --format=esm --target=node22 --outfile=api/[...path].js --packages=external", + "build:server": "esbuild server/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/server.js --packages=external", + "start": "node dist/server.js", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit", "lint": "eslint .", "test": "vitest run", - "verify": "npm run lint \u0026\u0026 npm run build \u0026\u0026 npm test", + "verify": "npm run lint && npm run build && npm test", "preview": "vite preview --host 127.0.0.1", "smoke:rpc": "node scripts/smoke-rpc.mjs", "format:check": "prettier --check .", @@ -19,13 +22,18 @@ }, "dependencies": { "@boing-network/boing-sdk": "0.5.40", + "@neondatabase/serverless": "^1.2.0", "@noble/ed25519": "^2.2.3", - "@noble/hashes": "^1.8.0" + "@noble/hashes": "^1.8.0", + "three": "^0.187.0" }, "devDependencies": { "@cloudflare/workers-types": "5.20261106.1", + "@electric-sql/pglite": "^0.5.15", "@eslint/js": "^9.39.0", "@types/node": "^24.19.2", + "@types/three": "^0.187.0", + "esbuild": "^0.28.2", "eslint": "^9.39.0", "prettier": "^3.9.10", "typescript": "~5.9.3", @@ -35,7 +43,7 @@ "wrangler": "4.158.0" }, "engines": { - "node": "\u003e=22.12" + "node": ">=22.12" }, "overrides": { "@typescript-eslint/typescript-estree": { diff --git a/public/_headers b/public/_headers index 39b4645..1c191c6 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,4 @@ /* X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin - X-Frame-Options: DENY - Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://boing.express; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' \ No newline at end of file + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://boing.express; img-src 'self' data:; frame-ancestors 'self' https://cursor.com https://*.cursor.com https://cursor.app https://*.cursor.app; base-uri 'self'; form-action 'self' \ No newline at end of file diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..4149531 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,218 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Pool, types } from "@neondatabase/serverless"; +import { postgresSchema } from "./postgres-schema"; + +const INT_FIELDS = new Set([ + "created_at", + "expires_at", + "min_height", + "day", + "n", +]); + +export interface SqlQuery { + (sql: string, params: unknown[]): Promise[]>; +} + +export interface Driver { + query: SqlQuery; + transaction(run: (query: SqlQuery) => Promise): Promise; +} + +export class Statement { + constructor( + private readonly driver: Driver, + readonly sql: string, + readonly values: unknown[] = [], + ) {} + bind(...values: unknown[]) { + return new Statement(this.driver, this.sql, values); + } + async first() { + const rows = await this.driver.query(this.sql, this.values); + return (rows[0] as T | undefined) ?? null; + } + async all() { + return { results: (await this.driver.query(this.sql, this.values)) as T[] }; + } + async run() { + await this.driver.query(this.sql, this.values); + } +} + +export interface PetsDb { + prepare(sql: string): Statement; + batch(statements: Statement[]): Promise; +} + +export function createDb(driver: Driver): PetsDb { + return { + prepare: (sql) => new Statement(driver, sql), + batch: (statements) => + driver.transaction(async (query) => { + for (const statement of statements) + await query(statement.sql, statement.values); + }), + }; +} + +export function normalizeRow(row: Record) { + const out: Record = { ...row }; + for (const key of INT_FIELDS) { + const value = out[key]; + if (typeof value === "bigint") out[key] = Number(value); + else if (typeof value === "string" && /^-?\d+$/.test(value)) { + const num = Number(value); + if (Number.isSafeInteger(num)) out[key] = num; + } + } + return out; +} + +export function toPg(sql: string) { + let index = 0; + let quoted = false; + let out = ""; + for (const char of sql) { + if (char === "'") quoted = !quoted; + if (char === "?" && !quoted) out += `$${++index}`; + else out += char; + } + return out; +} + +export function sqlStatements(sql: string) { + return sql + .split(";") + .map((part) => part.replace(/--.*$/gm, "").trim()) + .filter(Boolean); +} + +export function migrationPath(name: string) { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(process.cwd(), "server", name), + join(process.cwd(), "migrations", name), + join(here, name), + join(here, "..", "server", name), + join(here, "..", "migrations", name), + ]; + const found = candidates.find((path) => existsSync(path)); + if (!found) throw new Error(`Missing migration ${name}`); + return found; +} + +function readMigration(name: string) { + return readFileSync(migrationPath(name), "utf8"); +} + +export async function migrateSql(driver: Driver, sql: string) { + const statements = sqlStatements(sql); + await driver.transaction(async (query) => { + for (const statement of statements) await query(statement, []); + }); +} + +export async function migrate(driver: Driver, name: string) { + await migrateSql(driver, readMigration(name)); +} + +export function databaseUrl( + env: Record = process.env, +) { + return env.DATABASE_URL || env.POSTGRES_URL || env.POSTGRES_PRISMA_URL || ""; +} + +export function postgresConnectionString( + env: Record = process.env, +) { + return ( + env.DATABASE_URL_UNPOOLED || + env.POSTGRES_URL_NON_POOLING || + databaseUrl(env) + ); +} + +export function databaseMode( + env: Record = process.env, +) { + if (databaseUrl(env)) return "postgres" as const; + if (env.PETS_DB === "sqlite") return "sqlite" as const; + return "missing" as const; +} + +types.setTypeParser(20, (value: string) => Number(value)); + +function redact(error: unknown) { + const message = + error instanceof Error ? error.message : "Database connection failed"; + return message.replace(/postgres(?:ql)?:\/\/\S+/gi, "postgres://[redacted]"); +} + +export async function openPostgres(connectionString: string) { + const pool = await connectPostgres(connectionString); + const driver: Driver = { + query: async (sql, params) => { + const result = await pool.query(toPg(sql), params); + return result.rows.map((row) => normalizeRow(row)); + }, + transaction: async (run) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await run(async (sql, params) => { + const result = await client.query(toPg(sql), params); + return result.rows.map((row) => normalizeRow(row)); + }); + await client.query("COMMIT"); + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // The original query error is the one callers handle. + } + throw error; + } finally { + client.release(); + } + }, + }; + try { + await migrateSql(driver, postgresSchema); + } catch (error) { + await pool.end().catch(() => undefined); + throw error; + } + return { db: createDb(driver), close: () => pool.end() }; +} + +async function connectPostgres(connectionString: string) { + const pool = new Pool({ + connectionString, + max: 1, + connectionTimeoutMillis: 10_000, + }); + try { + await pool.query("SELECT 1"); + return pool; + } catch (error) { + await pool.end().catch(() => undefined); + throw new Error(redact(error)); + } +} + +export async function openDatabase( + env: Record = process.env, +) { + const mode = databaseMode(env); + if (mode === "postgres") return openPostgres(postgresConnectionString(env)); + if (mode === "sqlite") { + const { openSqlite } = await import("./sqlite"); + return openSqlite(env.PETS_SQLITE_PATH || ".data/pets.sqlite"); + } + throw new Error( + "DATABASE_URL is not set. The Neon integration injects it on Vercel. Local sqlite is only for PETS_DB=sqlite.", + ); +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..78d8737 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,252 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, normalize, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import app from "../worker/index"; +import { openDatabase, type PetsDb } from "./db"; +import { originFrom } from "./origin"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); +const types: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff2": "font/woff2", +}; + +export function securityHeaders() { + const file = join(root, "public", "_headers"); + const headers: Record = {}; + if (!existsSync(file)) return headers; + for (const line of readFileSync(file, "utf8").split("\n")) { + const trimmed = line.trim(); + if ( + !trimmed || + trimmed.startsWith("#") || + trimmed.startsWith("/") || + trimmed === "/*" + ) + continue; + const index = trimmed.indexOf(":"); + if (index <= 0) continue; + headers[trimmed.slice(0, index).trim()] = trimmed.slice(index + 1).trim(); + } + return headers; +} + +function publicOrigin(req: IncomingMessage) { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) + for (const item of value) headers.append(key, item); + else headers.set(key, value); + } + const host = headers.get("host") || "localhost"; + return originFrom(headers, new URL(`http://${host}/`)); +} + +function staticPath(directory: string, pathname: string) { + let decoded = pathname; + try { + decoded = decodeURIComponent(pathname); + } catch { + return null; + } + if (decoded.includes("\0")) return null; + const base = normalize(directory + sep); + const resolved = normalize(join(directory, decoded.replace(/^\/+/, ""))); + if (resolved !== base.slice(0, -1) && !resolved.startsWith(base)) return null; + return resolved; +} + +function send( + res: ServerResponse, + status: number, + body: Buffer | string, + headers: Record, +) { + res.writeHead(status, headers); + res.end(body); +} + +async function readBody(req: IncomingMessage) { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > 64 * 1024) { + req.destroy(); + return null; + } + if (size > 4096) continue; + chunks.push(buffer); + } + return size > 4096 ? null : Buffer.concat(chunks); +} + +export async function startServer( + options: { + port?: number; + host?: string; + database?: PetsDb; + staticRoot?: string; + env?: Record; + } = {}, +) { + const opened = options.database ? null : await openDatabase(options.env); + const database = options.database ?? opened?.db; + if (!database) throw new Error("Database did not open"); + const staticRoot = options.staticRoot ?? join(root, "dist", "client"); + const shared = securityHeaders(); + const server = createServer(async (req, res) => { + try { + const origin = publicOrigin(req); + const url = new URL(req.url || "/", origin); + if (!url.pathname.startsWith("/api/")) { + serveStatic(req, res, staticRoot, url.pathname, shared); + return; + } + const body = + req.method === "GET" || req.method === "HEAD" + ? undefined + : await readBody(req); + if (body === null) { + send(res, 413, JSON.stringify({ error: "Request too large" }), { + "content-type": "application/json; charset=utf-8", + ...shared, + "cache-control": "no-store", + }); + return; + } + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) + for (const item of value) headers.append(key, item); + else headers.set(key, value); + } + const response = await app.fetch( + new Request(url, { + method: req.method, + headers, + body: body?.length ? body : undefined, + }), + { + DB: database as unknown as D1Database, + ASSETS: { + fetch: async () => new Response("Not found", { status: 404 }), + } as unknown as Fetcher, + RPC_URL: process.env.RPC_URL || "https://testnet-rpc.boing.network", + APP_ORIGIN: origin, + }, + ); + const bytes = Buffer.from(await response.arrayBuffer()); + const out: Record = { ...shared }; + response.headers.forEach((value, key) => { + out[key] = value; + }); + send(res, response.status, bytes, out); + } catch (error) { + console.error(error instanceof Error ? error.message : "Request failed"); + send( + res, + 500, + JSON.stringify({ error: "Request could not be processed" }), + { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + ); + } + }); + await new Promise((resolve) => { + server.listen( + options.port ?? Number(process.env.PORT || 3000), + options.host ?? "0.0.0.0", + () => resolve(), + ); + }); + const address = server.address(); + const port = + typeof address === "object" && address + ? address.port + : Number(process.env.PORT || 3000); + return { + port, + close: async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await opened?.close(); + }, + }; +} + +function serveStatic( + req: IncomingMessage, + res: ServerResponse, + directory: string, + pathname: string, + shared: Record, +) { + const index = join(directory, "index.html"); + const target = pathname === "/" ? index : staticPath(directory, pathname); + const file = + target && existsSync(target) && statSync(target).isFile() ? target : null; + const leaf = pathname.split("/").pop() || ""; + const fallback = + !file && + (req.method === "GET" || req.method === "HEAD") && + !leaf.includes(".") + ? index + : file; + if (!fallback || !existsSync(fallback)) { + send(res, 404, "Not found", { + "content-type": "text/plain; charset=utf-8", + ...shared, + }); + return; + } + const extension = fallback.slice(fallback.lastIndexOf(".")); + send(res, 200, req.method === "HEAD" ? "" : readFileSync(fallback), { + ...shared, + "content-type": types[extension] || "application/octet-stream", + "cache-control": pathname.startsWith("/assets/") + ? "public, max-age=31536000, immutable" + : "no-cache", + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + startServer() + .then(({ port }) => { + const mode = + process.env.DATABASE_URL || + process.env.POSTGRES_URL || + process.env.POSTGRES_PRISMA_URL + ? "postgres" + : "sqlite"; + console.log(`Boing Pets listening on ${port} (${mode})`); + }) + .catch((error: unknown) => { + console.error( + error instanceof Error ? error.message : "Boing Pets failed to start", + ); + process.exit(1); + }); +} diff --git a/server/origin.ts b/server/origin.ts new file mode 100644 index 0000000..ff30a3e --- /dev/null +++ b/server/origin.ts @@ -0,0 +1,12 @@ +export function originFrom(headers: Headers, url: URL) { + if (process.env.APP_ORIGIN) return process.env.APP_ORIGIN; + const forwardedHost = headers.get("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || headers.get("host") || url.host; + const forwarded = headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + const local = /^localhost(?::|$)|127\.0\.0\.1(?::|$)|\[::1\](?::|$)/.test( + host, + ); + const proto = + forwarded || (local ? "http" : url.protocol.replace(":", "") || "https"); + return `${proto}://${host}`; +} diff --git a/server/postgres-schema.ts b/server/postgres-schema.ts new file mode 100644 index 0000000..b8433f5 --- /dev/null +++ b/server/postgres-schema.ts @@ -0,0 +1,106 @@ +export const postgresSchema = ` +CREATE TABLE IF NOT EXISTS challenges ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('hatch', 'feed', 'checkin')), + message TEXT NOT NULL, + amount TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + min_height BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS challenges_expiry ON challenges (expires_at); +CREATE INDEX IF NOT EXISTS challenges_owner ON challenges (owner, created_at); +CREATE TABLE IF NOT EXISTS pets ( + owner TEXT PRIMARY KEY, + hatch_tx TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY REFERENCES challenges (id), + owner TEXT NOT NULL REFERENCES pets (owner), + action TEXT NOT NULL CHECK (action IN ('hatch', 'feed', 'checkin')), + tx_hash TEXT UNIQUE, + created_at BIGINT NOT NULL, + day INTEGER NOT NULL, + UNIQUE (owner, action, day) +); +CREATE INDEX IF NOT EXISTS events_owner_time ON events (owner, created_at); +CREATE INDEX IF NOT EXISTS pets_recent ON pets (created_at DESC, owner); +CREATE TABLE IF NOT EXISTS kennel ( + id INTEGER PRIMARY KEY CHECK (id = 1), + contract TEXT NOT NULL UNIQUE, + deployer TEXT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS yard_challenges ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + action TEXT NOT NULL, + message TEXT NOT NULL, + amount TEXT NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + min_height BIGINT NOT NULL, + detail TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS pet_nfts ( + owner TEXT PRIMARY KEY, + token_id TEXT NOT NULL, + contract TEXT NOT NULL, + mint_tx TEXT NOT NULL UNIQUE, + staked INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS stalls ( + owner TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('pen', 'stand', 'toy')), + created_at BIGINT NOT NULL, + PRIMARY KEY (owner, kind) +); +CREATE TABLE IF NOT EXISTS yards ( + owner TEXT PRIMARY KEY, + kibble BIGINT NOT NULL, + settled_at BIGINT NOT NULL, + bonded INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS yard_events ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + action TEXT NOT NULL, + tx_hash TEXT, + created_at BIGINT NOT NULL, + day INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS skills ( + owner TEXT NOT NULL, + skill TEXT NOT NULL CHECK (skill IN ('forage', 'greet', 'play')), + points BIGINT NOT NULL, + last_day INTEGER NOT NULL, + PRIMARY KEY (owner, skill) +); +CREATE TABLE IF NOT EXISTS wardrobe ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + item TEXT NOT NULL, + source TEXT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE TABLE IF NOT EXISTS equipped ( + owner TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('head', 'neck', 'body', 'back', 'feet')), + piece TEXT NOT NULL, + PRIMARY KEY (owner, slot) +); +CREATE TABLE IF NOT EXISTS presence ( + owner TEXT PRIMARY KEY, + seen_at BIGINT NOT NULL, + x DOUBLE PRECISION, + z DOUBLE PRECISION, + pass TEXT, + pass_until BIGINT +); +ALTER TABLE presence ADD COLUMN IF NOT EXISTS x DOUBLE PRECISION; +ALTER TABLE presence ADD COLUMN IF NOT EXISTS z DOUBLE PRECISION; +ALTER TABLE presence ADD COLUMN IF NOT EXISTS pass TEXT; +ALTER TABLE presence ADD COLUMN IF NOT EXISTS pass_until BIGINT; +`; diff --git a/server/sqlite.ts b/server/sqlite.ts new file mode 100644 index 0000000..c312ec2 --- /dev/null +++ b/server/sqlite.ts @@ -0,0 +1,85 @@ +import { mkdirSync, readFileSync } from "node:fs"; +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; +import { dirname } from "node:path"; +import { + createDb, + migrationPath, + normalizeRow, + type Driver, +} from "./db"; + +export async function openSqlite(filename = ":memory:") { + if (filename !== ":memory:") mkdirSync(dirname(filename), { recursive: true }); + const db = new DatabaseSync(filename); + db.exec("PRAGMA foreign_keys = ON"); + const ready = db + .prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'pets'") + .get(); + if (!ready) db.exec(readFileSync(migrationPath("0001_pasture.sql"), "utf8")); + const yard = db + .prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'yards'") + .get(); + if (!yard) db.exec(readFileSync(migrationPath("0002_yard.sql"), "utf8")); + for (const sql of [ + "ALTER TABLE presence ADD COLUMN x REAL", + "ALTER TABLE presence ADD COLUMN z REAL", + "ALTER TABLE presence ADD COLUMN pass TEXT", + "ALTER TABLE presence ADD COLUMN pass_until INTEGER", + `CREATE TABLE IF NOT EXISTS wardrobe ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + item TEXT NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS equipped ( + owner TEXT NOT NULL, + slot TEXT NOT NULL CHECK (slot IN ('head', 'neck', 'body', 'back', 'feet')), + piece TEXT NOT NULL, + PRIMARY KEY (owner, slot) + )`, + `CREATE TABLE IF NOT EXISTS skills ( + owner TEXT NOT NULL, + skill TEXT NOT NULL CHECK (skill IN ('forage', 'greet', 'play')), + points INTEGER NOT NULL, + last_day INTEGER NOT NULL, + PRIMARY KEY (owner, skill) + )`, + ]) { + try { + db.exec(sql); + } catch { + // The column is already there on a pasture created before walking was added. + } + } + const driver: Driver = { + query: async (sql, params) => { + const statement = db.prepare(sql); + const values = params as SQLInputValue[]; + if (/^\s*(select|with)\b/i.test(sql)) { + return statement.all(...values).map((row) => normalizeRow(row as Record)); + } + statement.run(...values); + return []; + }, + transaction: async (run) => { + db.exec("BEGIN"); + try { + await run(async (sql, params) => { + const statement = db.prepare(sql); + const values = params as SQLInputValue[]; + if (/^\s*(select|with)\b/i.test(sql)) { + return statement.all(...values).map((row) => normalizeRow(row as Record)); + } + statement.run(...values); + return []; + }); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + }; + return { db: createDb(driver), close: () => db.close() }; +} diff --git a/server/vercel-handler.ts b/server/vercel-handler.ts new file mode 100644 index 0000000..c1a9b41 --- /dev/null +++ b/server/vercel-handler.ts @@ -0,0 +1,40 @@ +import app from "../worker/index"; +import { openPostgres, postgresConnectionString, type PetsDb } from "./db"; +import { originFrom } from "./origin"; + +let ready: Promise<{ db: PetsDb }> | undefined; + +function pasture() { + const url = postgresConnectionString(); + if (!url) throw new Error("DATABASE_URL is not set"); + ready ??= openPostgres(url).catch((error: unknown) => { + ready = undefined; + throw error; + }); + return ready; +} + +async function handle(request: Request) { + try { + const { db } = await pasture(); + const url = new URL(request.url); + return await app.fetch(request, { + DB: db as unknown as D1Database, + ASSETS: { + fetch: async () => new Response("Not found", { status: 404 }), + } as unknown as Fetcher, + RPC_URL: process.env.RPC_URL || "https://testnet-rpc.boing.network", + APP_ORIGIN: originFrom(request.headers, url), + }); + } catch (error) { + console.error(error instanceof Error ? error.message : "Pets API failed"); + return Response.json( + { error: "Pasture unavailable. Try again in a moment." }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } +} + +export const GET = handle; +export const POST = handle; +export default { fetch: handle }; diff --git a/src/main.ts b/src/main.ts index 8bbec81..021ab17 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createBoingWebWalletProvider } from "@boing-network/boing-sdk"; import { hex32, petSvg, traits, state, type Action, type Pet } from "./core"; +import { mountYard, type YardPet } from "./scene"; import "./style.css"; interface Provider { request(args: { method: string; params?: unknown[] }): Promise; @@ -32,7 +33,7 @@ let provider: Provider | null = null, const $ = (selector: string) => document.querySelector(selector)!; $("#app").innerHTML = - `
boingpets✦

BOING TESTNET · CHAIN 6913 MVP / LOCAL PREVIEW

A little bounce.
A buddy for life.

Hatch something wonderfully weird. Feed it a little love.
Your corner of the Boing universe is about to get cuter.

Meet your buddy ↓

No trading. No dying. Just good company on-chain.

Loading the pasture…

YOUR LITTLE CORNER

Home, sweet wallet.

Hatch & feed: ~0.001 BOING self-transfer + network fee. The transfer stays in your wallet. Check-in: free signature. Daily limits reset at midnight UTC.

A NEIGHBORHOOD OF HAPPY ACCIDENTS

The public pasture

Bouncy, peckish, a little scruffy — everybody belongs here.

01 / HATCH

One wallet. One weirdo.

Approve a real Boing testnet transaction in Express. Your wallet and hatch transaction reveal one of ten species, with its own colors and quirks.

02 / SAY HELLO

Make a little ritual.

A daily signed hello builds your streak and happiness. Feeding fills the belly. Both count as a care day, but more transactions never buy a longer streak.

03 / ALWAYS WELCOME

Missed a few days? It's okay.

After two days without food, buddies get scruffy. After four, they roam as strays. A feed always brings them home — no pet is ever permanently lost.

`; + `
boingpets✦

BOING TESTNET · CHAIN 6913 MVP / TESTNET

A little bounce.
A buddy for life.

Hatch something wonderfully weird. Feed it a little love.
Your corner of the Boing universe is about to get cuter.

Meet your buddy ↓

No trading. No dying. Just good company on-chain.

Loading the pasture…

YOUR LITTLE CORNER

Home, sweet wallet.

Hatch & feed: ~0.001 BOING self-transfer + network fee. The transfer stays in your wallet. Check-in: free signature. Daily limits reset at midnight UTC.

THE SHARED YARD

Everyone's buddy, in one place.

Hatch a buddy to start a yard.

Mint and bond are real testnet transactions. Staking checks that your wallet still holds the pet NFT. Kibble follows the belly and the mood. A snack stand only sells after a feeding, toys only pay when a buddy is glad, and a pen shelters a scruffy pet but cannot feed a stray. Earnings pause after three days until you collect. Mint, bond, and feed are the real testnet actions.

A NEIGHBORHOOD OF HAPPY ACCIDENTS

The public pasture

Bouncy, peckish, a little scruffy — everybody belongs here.

01 / HATCH

One wallet. One weirdo.

Approve a real Boing testnet transaction in Express. Your wallet and hatch transaction reveal one of ten species, with its own colors and quirks.

02 / SAY HELLO

Make a little ritual.

A daily signed hello builds your streak and happiness. Feeding fills the belly. Both count as a care day, but more transactions never buy a longer streak.

03 / ALWAYS WELCOME

Missed a few days? It's okay.

After two days without food, buddies get scruffy. After four, they roam as strays. A feed always brings them home — no pet is ever permanently lost.

`; const demoOwner = "0x" + "11".repeat(32); $("#hero-pets").innerHTML = [1, 2, 3] .map( @@ -78,6 +79,19 @@ function controls() { "refresh", "next", "prev", + "deploy-nft", + "mint-nft", + "stake-nft", + "unstake-nft", + "bond", + "unbond", + "collect", + "train-forage", + "train-greet", + "train-play", + "buy-pen", + "buy-stand", + "buy-toy", ]) $("#" + id).toggleAttribute("disabled", busy); $("#hatch").hidden = !!mine; @@ -237,12 +251,41 @@ $("#connect").onclick = () => } provider.on?.("accountsChanged", disconnect); provider.on?.("chainChanged", disconnect); + localStorage.setItem("boing-pets-wallet", owner); await refresh(); notice("Connected on Boing testnet. Your pasture awaits."); }); +async function resumeWallet() { + if (!localStorage.getItem("boing-pets-wallet")) return; + const injected = (window as Window & { boing?: Provider }).boing; + const next = + injected || + (createBoingWebWalletProvider({ + chainId: "0x1b01", + mode: "popup", + }) as Provider); + try { + const accounts = (await next.request({ method: "boing_accounts", params: [] })) as string[]; + if (!accounts?.[0]) { + localStorage.removeItem("boing-pets-wallet"); + return; + } + provider = next; + owner = hex32(accounts[0]); + localStorage.setItem("boing-pets-wallet", owner); + provider.on?.("accountsChanged", disconnect); + provider.on?.("chainChanged", disconnect); + notice("Still connected on Boing testnet."); + } catch { + localStorage.removeItem("boing-pets-wallet"); + } +} function disconnect() { + (provider as Provider & { disconnect?: () => void })?.disconnect?.(); provider?.removeListener?.("accountsChanged", disconnect); provider?.removeListener?.("chainChanged", disconnect); + localStorage.removeItem("boing-pets-wallet"); + localStorage.removeItem("boing-pets-wander"); owner = ""; mine = null; provider = null; @@ -282,12 +325,117 @@ $("#prev").onclick = () => offset = Math.max(0, offset - 24); await refresh(); }); +const yardView = mountYard($("#yard-canvas")); +yardView.onArrive((x, z) => { + const pass = localStorage.getItem("boing-pets-wander"); + if (!owner || !pass) return; + void api("/api/presence", { owner, x, z, pass }).catch(() => undefined); +}); +async function loadYard() { + const data = await api<{ + collection: string | null; + me: { + kibble: number; + rate: number; + minted: boolean; + staked: boolean; + bonded: boolean; + buildings: string[]; + stage?: string; + skills?: { forage: number; greet: number; play: number }; + practiced?: string[]; + wardrobe?: { id: string; name: string; slot: string; rarity: string; equipped: boolean }[]; + } | null; + pets: YardPet[]; + }>("/api/yard" + (owner ? "?viewer=" + owner : "")); + yardView.follow(owner); + yardView.update(data.pets); + const me = data.me; + $("#yard-stats").textContent = me + ? `${me.stage ?? "grown"} · ${me.kibble} kibble · ${me.rate}/day · forage ${me.skills?.forage ?? 0} · greet ${me.skills?.greet ?? 0} · play ${me.skills?.play ?? 0}` + : data.collection + ? "Kennel is open. Hatch and mint to stand in the yard." + : "The NFT kennel is not open yet. The first buddy can deploy it."; + $("#deploy-nft").hidden = Boolean(data.collection); + $("#mint-nft").hidden = !owner || Boolean(me?.minted) || !data.collection; + $("#stake-nft").hidden = !me?.minted || Boolean(me.staked); + $("#unstake-nft").hidden = !me?.staked; + $("#bond").hidden = !me?.minted || Boolean(me.bonded); + $("#unbond").hidden = !me?.bonded; + for (const [id, kind] of [ + ["buy-pen", "pen"], + ["buy-stand", "stand"], + ["buy-toy", "toy"], + ] as const) + $("#" + id).hidden = !me || me.buildings.includes(kind); + $("#collect").hidden = !me; + const practiced = new Set(me?.practiced ?? []); + $("#train-forage").hidden = practiced.has("forage"); + $("#train-greet").hidden = practiced.has("greet"); + $("#train-play").hidden = practiced.has("play"); + const closet = $("#wardrobe"); + closet.replaceChildren(); + if (me && !(me.wardrobe ?? []).length) { + const note = document.createElement("p"); + note.className = "subtle"; + note.textContent = + "Five clothing slots: head, neck, body, back, and feet. Forage sometimes turns up a common piece. Rarer clothes come from minigames."; + closet.append(note); + } + for (const piece of me?.wardrobe ?? []) { + const button = document.createElement("button"); + button.className = piece.equipped ? "" : "secondary"; + button.textContent = piece.equipped + ? `Take off ${piece.name}` + : `Equip ${piece.name} · ${piece.rarity} ${piece.slot}`; + button.onclick = () => + void run(() => yardAct(piece.equipped ? "unequip" : "equip", piece.equipped ? { slot: piece.slot } : { piece: piece.id })); + closet.append(button); + } +} +async function yardAct(action: string, extra?: Record) { + await assertWallet(); + if (pending()?.owner === owner) throw new Error("Confirm your pending transaction first; do not pay twice."); + const c = await api("/api/challenge", { owner, action, ...extra }); + if (!c.transaction) { + notice("Approve the signature in Express."); + const signature = await provider!.request({ method: "boing_signMessage", params: [c.message, owner] }); + const done = await api<{ pass?: string }>("/api/confirm", { id: c.id, signature }); + if (done.pass) localStorage.setItem("boing-pets-wander", done.pass); + await loadYard(); + notice( + action === "train_forage" + ? "Forage finished. A common piece lands in the wardrobe when the pasture had one." + : "Yard updated.", + ); + return; + } + notice("Approve the testnet transaction in Express."); + const result = (await provider!.request({ method: "boing_sendTransaction", params: [c.transaction] })) as + | { tx_id?: string; tx_hash?: string } + | string; + const hash = hex32(typeof result === "string" ? result : result.tx_id || result.tx_hash); + localStorage.setItem("boing-pets-pending", JSON.stringify({ id: c.id, owner: c.owner, tx_hash: hash })); + await confirmPending(); + await loadYard(); +} +for (const action of ["deploy-nft", "mint-nft", "stake-nft", "unstake-nft", "bond", "unbond", "collect", "buy-pen", "buy-stand", "buy-toy"]) + $("#" + action).onclick = () => void run(() => yardAct(action.replace("buy-", "buy_").replaceAll("-", "_"))); +setInterval(() => { + if (!owner) return; + void api("/api/presence", { owner }).then(() => loadYard()).catch(() => undefined); +}, 20_000); controls(); +$("#yard-canvas").addEventListener("pointerdown", () => { + if (!owner) { + notice("Connect Express, then click the grass to walk."); + return; + } + if (localStorage.getItem("boing-pets-wander")) return; + void run(() => yardAct("wander")); +}); void refresh() + .then(() => resumeWallet()) + .then(() => loadYard()) .then(() => notice("Testnet pasture ready. Make yourself at home.")) - .catch(() => - notice( - "Pasture API unavailable. Start the local API and apply the D1 migration (see README).", - true, - ), - ); + .catch(() => notice("Pasture API unavailable. Try again in a moment.", true)); diff --git a/src/nft.ts b/src/nft.ts new file mode 100644 index 0000000..8358614 --- /dev/null +++ b/src/nft.ts @@ -0,0 +1,104 @@ +import { blake3 } from "@noble/hashes/blake3"; +import { bytesToHex } from "@noble/hashes/utils"; +import { + buildReferenceNftCollectionDeployMetaTx, + decodeReferenceNftCalldata, + encodeReferenceMintBatchCalldataHex, + predictNonceDerivedContractAddress, + referenceNftOwnerStorageKey, + referenceNftTokenIdWordFromU64, +} from "@boing-network/boing-sdk"; +import { hex32 } from "./core"; +import { BOND_AMOUNT } from "./tycoon"; + +export function petTokenId(owner: string) { + const n = BigInt(`0x${hex32(owner).slice(2, 14)}`) % 2n ** 32n; + return referenceNftTokenIdWordFromU64(n === 0n ? 1n : n); +} + +export function petMetadataHash(owner: string) { + return `0x${bytesToHex(blake3(new TextEncoder().encode(`boing.pets:nft:v1:${hex32(owner)}`)))}`; +} + +export function predictedKennel(owner: string, nonce: bigint) { + return predictNonceDerivedContractAddress(hex32(owner), nonce); +} + +export function deployKennelTx() { + return { + ...buildReferenceNftCollectionDeployMetaTx({ + collectionName: "Boing Pets", + collectionSymbol: "PET", + purposeCategory: "nft", + }), + display: "Boing Pets: deploy the shared pet NFT kennel on testnet.", + }; +} + +export function mintPetTx(owner: string, contract: string) { + const tokenId = petTokenId(owner); + const calldata = encodeReferenceMintBatchCalldataHex(owner, [tokenId], [petMetadataHash(owner)]); + const accounts = [hex32(owner), hex32(contract)].sort(); + return { + type: "contract_call" as const, + contract: hex32(contract), + calldata, + access_list: { read: accounts, write: accounts }, + display: "Boing Pets: mint your pet NFT into your wallet.", + }; +} + +export function bondTx(unbond = false) { + return { + type: unbond ? "unbond" : "bond", + amount: BOND_AMOUNT, + display: unbond + ? "Boing Pets: unbond 0.01 testnet BOING from your pet stake." + : "Boing Pets: bond 0.01 testnet BOING to stake with your pet NFT.", + }; +} + +export function ownerStorageKey(tokenId: string) { + return referenceNftOwnerStorageKey(tokenId); +} + +function payload(tx: Record) { + const body = tx.payload; + if (!body || typeof body !== "object") throw new Error("Transaction has no payload"); + return body as Record; +} + +function variant(tx: Record, names: string[]) { + const body = payload(tx); + for (const name of names) if (body[name] && typeof body[name] === "object") return body[name] as Record; + return null; +} + +export function verifyDeployTx(tx: Record, owner: string, nonce: number, contract: string) { + if (hex32(tx.sender) !== hex32(owner)) throw new Error("Deploy belongs to another wallet"); + if (Number(tx.nonce) !== nonce) throw new Error("Deploy nonce does not match this challenge"); + const deploy = variant(tx, [ + "ContractDeployWithPurposeAndMetadata", + "ContractDeployWithPurpose", + "contract_deploy_meta", + ]); + if (!deploy) throw new Error("Expected a Boing NFT collection deploy"); + if (hex32(predictedKennel(owner, BigInt(nonce))) !== hex32(contract)) + throw new Error("Kennel address does not match the deployer nonce"); +} + +export function verifyMintTx(tx: Record, owner: string, contract: string, tokenId: string) { + if (hex32(tx.sender) !== hex32(owner)) throw new Error("Mint belongs to another wallet"); + const call = variant(tx, ["ContractCall", "contract_call"]); + if (!call || hex32(call.contract) !== hex32(contract)) throw new Error("Mint called a different contract"); + const decoded = decodeReferenceNftCalldata(String(call.calldata)); + if (!decoded || decoded.selector !== 6) throw new Error("Expected a reference NFT mint"); + if (hex32(decoded.to) !== hex32(owner) || !decoded.tokenIds.some((id) => hex32(id) === hex32(tokenId))) + throw new Error("Mint was not for this wallet's pet NFT"); +} + +export function verifyBondTx(tx: Record, owner: string, unbond = false) { + if (hex32(tx.sender) !== hex32(owner)) throw new Error("Stake belongs to another wallet"); + const bond = variant(tx, unbond ? ["Unbond", "unbond"] : ["Bond", "bond"]); + if (!bond || String(bond.amount) !== BOND_AMOUNT) throw new Error("Expected a 0.01 BOING bond"); +} diff --git a/src/scene.ts b/src/scene.ts new file mode 100644 index 0000000..a20de21 --- /dev/null +++ b/src/scene.ts @@ -0,0 +1,389 @@ +import * as THREE from "three"; +import { FIELD_RADIUS, pasturePlace, stageScale, yardSpot, type Stage } from "./tycoon"; +import { accessory, type Rarity } from "./wardrobe"; +import type { traits } from "./core"; + +export interface YardPet { + owner: string; + traits: ReturnType; + state: { status: string; hunger: number; happiness: number }; + stage?: Stage; + wandered?: boolean; + spot: { x: number; z: number }; + online: boolean; + nft: { staked: boolean } | null; + buildings: string[]; + wear?: { slot: string; item: string }[]; +} + +const RARITY_METAL: Record = { + common: 0, + uncommon: 0.05, + rare: 0.15, + epic: 0.35, + mythic: 0.55, +}; + +function wearMaterial(rarity: Rarity, tint: string) { + return new THREE.MeshStandardMaterial({ + color: tint, + roughness: rarity === "common" ? 0.85 : 0.4, + metalness: RARITY_METAL[rarity], + emissive: rarity === "mythic" || rarity === "epic" ? tint : "#000000", + emissiveIntensity: rarity === "mythic" ? 0.35 : rarity === "epic" ? 0.18 : 0, + }); +} + +function addWear(group: THREE.Group, wear: { slot: string; item: string }[] = []) { + for (const piece of wear) { + const item = accessory(piece.item); + if (!item) continue; + const material = wearMaterial(item.rarity, item.tint); + const slot = item.slot; + if (slot === "head") { + const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.05, 16), material); + brim.position.y = 1.22; + const crown = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.22, 0.22, 12), material); + crown.position.y = 1.34; + group.add(brim, crown); + } else if (slot === "neck") { + const scarf = new THREE.Mesh(new THREE.TorusGeometry(0.58, 0.07, 8, 20), material); + scarf.rotation.x = Math.PI / 2; + scarf.position.y = 0.9; + group.add(scarf); + } else if (slot === "body") { + const wrap = new THREE.Mesh(new THREE.CylinderGeometry(0.68, 0.72, 0.2, 20), material); + wrap.position.y = 0.48; + group.add(wrap); + } else if (slot === "back") { + const pack = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.42, 0.2), material); + pack.position.set(0.5, 0.82, -0.08); + group.add(pack); + } else if (slot === "feet") { + for (const side of [-1, 1]) { + const boot = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.14, 0.28), material); + boot.position.set(side * 0.18, 0.08, 0.1); + group.add(boot); + } + } + } +} + +const fur = (hue: number, accent: number) => ({ + coat: new THREE.Color(`hsl(${hue} 42% 46%)`), + mark: new THREE.Color(`hsl(${accent} 38% 34%)`), +}); + +function grassTexture() { + const canvas = document.createElement("canvas"); + canvas.width = 256; + canvas.height = 256; + const pen = canvas.getContext("2d"); + if (!pen) return null; + pen.fillStyle = "#2f6a3a"; + pen.fillRect(0, 0, 256, 256); + for (let i = 0; i < 1400; i += 1) { + const x = (i * 47) % 256; + const y = (i * 91) % 256; + pen.fillStyle = i % 3 === 0 ? "#3e7c45" : "#245c30"; + pen.fillRect(x, y, 2, i % 5 === 0 ? 5 : 2); + } + pen.fillStyle = "rgba(92, 74, 48, 0.55)"; + pen.beginPath(); + pen.ellipse(128, 150, 28, 70, 0.4, 0, Math.PI * 2); + pen.fill(); + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(4, 4); + return texture; +} + +function addSpecies(group: THREE.Group, pet: YardPet, coat: THREE.Color, mark: THREE.Color) { + const scruffy = pet.state.status === "scruffy" || pet.state.status === "stray"; + const material = new THREE.MeshStandardMaterial({ + color: coat, + roughness: scruffy ? 0.92 : 0.62, + metalness: 0.02, + }); + const accent = new THREE.MeshStandardMaterial({ color: mark, roughness: 0.7 }); + const species = pet.traits.species; + const body = new THREE.Mesh( + species === "Pebblit" + ? new THREE.SphereGeometry(0.48, 20, 14) + : species === "Axobop" + ? new THREE.SphereGeometry(0.5, 20, 14) + : new THREE.SphereGeometry(species === "Cloudling" ? 0.42 : 0.5, 22, 16), + material, + ); + body.scale.set(species === "Pebblit" ? 1.25 : species === "Axobop" ? 1.35 : 1, species === "Sprout" ? 1.15 : species === "Pebblit" ? 0.72 : 1, 1); + body.position.y = species === "Pebblit" ? 0.38 : 0.62; + body.castShadow = true; + group.add(body); + group.scale.setScalar(stageScale(pet.stage ?? "grown")); + if (species === "Cloudling") { + for (const spot of [ + [-0.28, 0.7, 0.1], + [0.3, 0.78, -0.05], + ] as const) { + const puff = new THREE.Mesh(new THREE.SphereGeometry(0.28, 16, 12), material); + puff.position.set(spot[0], spot[1], spot[2]); + puff.castShadow = true; + group.add(puff); + } + } + if (species === "Mooncat" || species === "Bunbounce") { + const height = species === "Bunbounce" ? 0.55 : 0.28; + for (const side of [-1, 1]) { + const ear = new THREE.Mesh(new THREE.ConeGeometry(0.12, height, 10), accent); + ear.position.set(side * 0.18, 1.05, 0); + ear.castShadow = true; + group.add(ear); + } + } + if (species === "Sprout" || species === "Mushling") { + const cap = new THREE.Mesh( + new THREE.SphereGeometry(species === "Mushling" ? 0.38 : 0.16, 14, 10), + accent, + ); + cap.scale.y = species === "Sprout" ? 1.8 : 0.55; + cap.position.y = species === "Mushling" ? 1.05 : 1.25; + cap.castShadow = true; + group.add(cap); + } + if (species === "Starfin" || species === "Axobop") { + const fin = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.45, 8), accent); + fin.rotation.z = Math.PI / 2; + fin.position.set(-0.55, 0.6, 0); + group.add(fin); + } + if (species === "Boingle" || pet.traits.antenna === 1) { + const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.35, 6), accent); + stem.position.y = 1.2; + group.add(stem); + const tip = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 8), accent); + tip.position.y = 1.4; + group.add(tip); + } + for (let i = 0; i < pet.traits.spots; i += 1) { + const spot = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 8), accent); + spot.position.set(-0.15 + i * 0.14, 0.7, 0.42); + group.add(spot); + } + const eyeY = pet.state.status === "stray" || pet.state.status === "scruffy" ? 0.68 : 0.78; + for (const side of [-1, 1]) { + const eye = new THREE.Mesh( + new THREE.SphereGeometry(0.045, 8, 8), + new THREE.MeshStandardMaterial({ color: "#1b2436", roughness: 0.4 }), + ); + eye.position.set(side * 0.16, eyeY, 0.42); + group.add(eye); + } + addWear(group, pet.wear); +} + +function addBuildings(group: THREE.Group, kinds: string[]) { + const wood = new THREE.MeshStandardMaterial({ color: "#8a623c", roughness: 0.85 }); + const cloth = new THREE.MeshStandardMaterial({ color: "#d9e4c4", roughness: 0.8 }); + if (kinds.includes("pen")) { + for (const [x, z] of [ + [0.85, 0.35], + [1.45, 0.35], + [0.85, -0.35], + [1.45, -0.35], + ] as const) { + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.05, 0.55, 6), wood); + post.position.set(x, 0.28, z); + post.castShadow = true; + group.add(post); + } + const rail = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.05, 0.05), wood); + rail.position.set(1.15, 0.42, 0.35); + group.add(rail); + } + if (kinds.includes("stand")) { + const counter = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.28, 0.38), wood); + counter.position.set(-1.05, 0.2, 0.15); + counter.castShadow = true; + group.add(counter); + const awning = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.05, 0.46), cloth); + awning.position.set(-1.05, 0.62, 0.15); + group.add(awning); + } + if (kinds.includes("toy")) { + const ball = new THREE.Mesh( + new THREE.SphereGeometry(0.12, 12, 10), + new THREE.MeshStandardMaterial({ color: "#c4554a", roughness: 0.55 }), + ); + ball.position.set(0.2, 0.12, 0.85); + ball.castShadow = true; + group.add(ball); + } +} + +export function mountYard(canvas: HTMLCanvasElement) { + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1)); + renderer.shadowMap.enabled = true; + const scene = new THREE.Scene(); + scene.background = new THREE.Color("#6f93c4"); + scene.fog = new THREE.Fog("#6f93c4", 22, 52); + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 90); + camera.position.set(0.5, 9, 18); + camera.lookAt(0, 0.3, 0); + scene.add(new THREE.HemisphereLight("#d5e6ff", "#3e6a38", 1.15)); + const sun = new THREE.DirectionalLight("#fff1d6", 1.8); + sun.position.set(-8, 14, 6); + sun.castShadow = true; + sun.shadow.camera.near = 1; + sun.shadow.camera.far = 40; + sun.shadow.camera.left = -14; + sun.shadow.camera.right = 14; + sun.shadow.camera.top = 14; + sun.shadow.camera.bottom = -14; + scene.add(sun); + const texture = grassTexture(); + const ground = new THREE.Mesh( + new THREE.CircleGeometry(FIELD_RADIUS + 4, 72), + new THREE.MeshStandardMaterial({ map: texture ?? undefined, color: "#356b3c", roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + const wood = new THREE.MeshStandardMaterial({ color: "#6e8f52", roughness: 0.9 }); + for (let i = 0; i < 18; i += 1) { + const angle = (i / 18) * Math.PI * 2; + const tree = new THREE.Group(); + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 1.1, 6), new THREE.MeshStandardMaterial({ color: "#6b4a32", roughness: 0.9 })); + trunk.position.y = 0.55; + trunk.castShadow = true; + const crown = new THREE.Mesh(new THREE.ConeGeometry(0.7, 1.6, 8), wood); + crown.position.y = 1.7; + crown.castShadow = true; + tree.add(trunk, crown); + tree.position.set(Math.cos(angle) * 16, 0, Math.sin(angle) * 16); + scene.add(tree); + } + const rock = new THREE.MeshStandardMaterial({ color: "#8d8a84", roughness: 1 }); + for (let i = 0; i < 7; i += 1) { + const stone = new THREE.Mesh(new THREE.DodecahedronGeometry(0.28 + (i % 3) * 0.08), rock); + stone.position.set(Math.cos(i * 1.7) * 14, 0.15, Math.sin(i * 2.1) * 16); + stone.castShadow = true; + scene.add(stone); + } + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const pets = new Map(); + let root = new THREE.Group(); + scene.add(root); + let frame = 0; + let running = true; + let follow = ""; + let arrive: (x: number, z: number) => void = () => undefined; + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + canvas.addEventListener("pointerdown", (event) => { + if (!follow || !pets.has(follow)) return; + const rect = canvas.getBoundingClientRect(); + pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + const hit = raycaster.intersectObject(ground, false)[0]; + if (!hit) return; + const reach = Math.hypot(hit.point.x, hit.point.z) || 1; + const limit = Math.min(1, FIELD_RADIUS / reach); + const pet = pets.get(follow); + if (!pet) return; + pet.target = new THREE.Vector3(hit.point.x * limit, 0, hit.point.z * limit); + }); + function resize() { + const width = canvas.clientWidth || 640; + const height = canvas.clientHeight || 420; + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + function update(list: YardPet[]) { + const walking = follow ? pets.get(follow) : undefined; + const keep = walking?.target + ? { x: walking.group.position.x, z: walking.group.position.z, target: walking.target } + : null; + scene.remove(root); + root = new THREE.Group(); + scene.add(root); + pets.clear(); + for (const pet of list) { + const colors = fur(pet.traits.hue, pet.traits.accent); + const home = new THREE.Group(); + const nest = yardSpot(pet.owner); + home.position.set(nest.x, 0, nest.z); + addBuildings(home, pet.buildings); + root.add(home); + const group = new THREE.Group(); + addSpecies(group, pet, colors.coat, colors.mark); + const place = pet.wandered ? pet.spot : pasturePlace(pet.owner, pet.state.status, Boolean(pet.nft?.staked)); + group.position.set(place.x, 0, place.z); + const target = keep && pet.owner === follow ? keep.target : null; + if (keep && pet.owner === follow) group.position.set(keep.x, 0, keep.z); + if (pet.state.status === "stray") group.rotation.z = 0.6; + else if (pet.state.status === "scruffy") group.rotation.x = 0.25; + if (pet.nft?.staked) { + const collar = new THREE.Mesh( + new THREE.TorusGeometry(0.28, 0.035, 8, 18), + new THREE.MeshStandardMaterial({ color: "#c6a15a", roughness: 0.45, metalness: 0.35 }), + ); + collar.rotation.x = Math.PI / 2; + collar.position.y = 0.95; + group.add(collar); + } + root.add(group); + pets.set(pet.owner, { group, phase: place.x, status: pet.state.status, key: "", target }); + } + } + function loop() { + if (!running) return; + frame += 1; + const now = performance.now(); + if (!reduced) { + for (const [owner, pet] of pets) { + const energy = pet.status === "bouncy" ? 0.06 : pet.status === "peckish" ? 0.025 : 0; + if (pet.target) { + const step = pet.target.clone().sub(pet.group.position); + step.y = 0; + const distance = step.length(); + if (distance < 0.2) { + pet.group.position.x = pet.target.x; + pet.group.position.z = pet.target.z; + const arrived = pet.target; + pet.target = null; + if (owner === follow) arrive(arrived.x, arrived.z); + } else { + step.multiplyScalar(Math.min(1, 4.2 / distance) * 0.016); + pet.group.position.add(step); + pet.group.lookAt(pet.target.x, pet.group.position.y, pet.target.z); + } + } + pet.group.position.y = Math.sin(frame / 28 + pet.phase) * energy; + } + } + if (follow && pets.has(follow)) { + const at = pets.get(follow)!.group.position; + camera.position.lerp(new THREE.Vector3(at.x + 7, 6.4, at.z + 9), 0.06); + camera.lookAt(at.x, 0.4, at.z); + } + resize(); + renderer.render(scene, camera); + void now; + requestAnimationFrame(loop); + } + loop(); + return { + update, + follow(owner: string) { + follow = owner; + }, + onArrive(listener: (x: number, z: number) => void) { + arrive = listener; + }, + stop: () => (running = false), + }; +} diff --git a/src/style.css b/src/style.css index b0507b4..668e398 100644 --- a/src/style.css +++ b/src/style.css @@ -220,6 +220,23 @@ h3 { color: var(--muted); font-size: 0.78rem; } +.wardrobe { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 4px; +} +.wardrobe button { + font-size: 0.74rem; +} +#yard-canvas { + width: 100%; + height: 420px; + display: block; + border-radius: 28px; + background: #6f93c4; + margin: 8px 0 18px; +} .gallery { display: grid; grid-template-columns: repeat(3, 1fr); diff --git a/src/tycoon.ts b/src/tycoon.ts new file mode 100644 index 0000000..54130f5 --- /dev/null +++ b/src/tycoon.ts @@ -0,0 +1,128 @@ +import { DAY, dayOf, hex32, type Action } from "./core"; + +export const YARD_ACTIONS = [ + "deploy_nft", + "mint_nft", + "stake_nft", + "unstake_nft", + "bond", + "unbond", + "collect", + "buy_pen", + "buy_stand", + "buy_toy", + "train_forage", + "train_greet", + "train_play", + "wander", + "equip", + "unequip", +] as const; +export type YardAction = (typeof YARD_ACTIONS)[number]; +export type Building = "pen" | "stand" | "toy"; +export const BUILDINGS: Record = { + pen: { cost: 40, daily: 22, label: "Boarding pen" }, + stand: { cost: 70, daily: 30, label: "Snack stand" }, + toy: { cost: 55, daily: 20, label: "Toy cart" }, +}; +export const BOND_AMOUNT = (10n ** 16n).toString(); +export const FIELD_RADIUS = 30; +export const SKILLS = ["forage", "greet", "play"] as const; +export type Skill = (typeof SKILLS)[number]; +export type Stage = "hatchling" | "juvenile" | "grown" | "elder"; +const ACCRUAL_CAP = 3 * DAY; + +export function isYardAction(action: string): action is YardAction { + return (YARD_ACTIONS as readonly string[]).includes(action); +} + +export function buildingOf(action: YardAction): Building | null { + if (action === "buy_pen") return "pen"; + if (action === "buy_stand") return "stand"; + if (action === "buy_toy") return "toy"; + return null; +} + +export function yardSpot(owner: string) { + const hex = hex32(owner).slice(2); + const x = parseInt(hex.slice(0, 4), 16) / 65535; + const z = parseInt(hex.slice(4, 8), 16) / 65535; + return { x: Math.round((x - 0.5) * 180) / 10, z: Math.round((z - 0.5) * 180) / 10 }; +} + +export function pasturePlace(owner: string, status: string, staked: boolean) { + const home = yardSpot(owner); + if (staked || status === "bouncy") return home; + if (status === "peckish") return { x: home.x * 0.85, z: home.z * 0.85 }; + if (status === "scruffy") return { x: home.x * 1.2, z: home.z * 1.2 }; + const length = Math.hypot(home.x, home.z) || 1; + return { + x: Math.round((home.x / length) * 85) / 10, + z: Math.round((home.z / length) * 85) / 10, + }; +} + +export function evolutionStage(ageMs: number, careEvents: number, skillPoints: number): Stage { + const score = careEvents + skillPoints + Math.floor(Math.max(0, ageMs) / DAY); + if (score >= 18) return "elder"; + if (score >= 8) return "grown"; + if (score >= 3) return "juvenile"; + return "hatchling"; +} + +export function stageScale(stage: Stage) { + if (stage === "hatchling") return 0.62; + if (stage === "juvenile") return 0.84; + if (stage === "elder") return 1.12; + return 1; +} + +export function dailyRate(input: { + hunger: number; + happiness: number; + status: string; + buildings: readonly Building[]; + nftStaked: boolean; + bonded: boolean; + forage?: number; + greet?: number; + play?: number; +}) { + const belly = Math.min(1, Math.max(0, input.hunger / 100)); + const joy = Math.min(1, Math.max(0, input.happiness / 100)); + // A buddy earns a living from being fed and glad. Buildings only help when they have something to work with. + const forage = Math.min(12, input.forage ?? 0); + const greet = Math.min(12, input.greet ?? 0); + const play = Math.min(12, input.play ?? 0); + let rate = 8 * (0.3 + 0.7 * belly) * (0.4 + 0.6 * joy); + rate += forage * 0.35 * (0.4 + belly); + rate += greet * 0.3 * joy; + if (input.buildings.includes("pen")) rate += input.status === "stray" ? 8 : BUILDINGS.pen.daily; + if (input.buildings.includes("stand")) rate += BUILDINGS.stand.daily * belly * (1 + forage / 40); + if (input.buildings.includes("toy")) rate += BUILDINGS.toy.daily * joy * (1 + play / 30); + if (input.nftStaked) rate *= 1.25; + if (input.buildings.length && !input.bonded) rate *= 0.9; + if (input.bonded) rate *= 1.1; + return Math.max(0, Math.round(rate)); +} + +export function previewKibble(input: { + stored: number; + settledAt: number; + now: number; + ratePerDay: number; +}) { + const elapsed = Math.max(0, Math.min(ACCRUAL_CAP, input.now - input.settledAt)); + const gained = Math.floor((input.ratePerDay * elapsed) / DAY); + return { kibble: input.stored + gained, gained, settledAt: input.settledAt + elapsed }; +} + +export function online(seenAt: number | null, now: number) { + return seenAt !== null && now - seenAt < 60_000; +} + +export function careAction(action: string): action is Action { + return action === "hatch" || action === "feed" || action === "checkin"; +} + +export { dayOf }; diff --git a/src/wardrobe.ts b/src/wardrobe.ts new file mode 100644 index 0000000..4bfbc2d --- /dev/null +++ b/src/wardrobe.ts @@ -0,0 +1,50 @@ +import { blake3 } from "@noble/hashes/blake3"; + +export const SLOTS = ["head", "neck", "body", "back", "feet"] as const; +export type Slot = (typeof SLOTS)[number]; +export const RARITIES = ["common", "uncommon", "rare", "epic", "mythic"] as const; +export type Rarity = (typeof RARITIES)[number]; + +export interface Accessory { + id: string; + slot: Slot; + rarity: Rarity; + name: string; + tint: string; +} + +/** Fixed catalog. Future minigames grant by these ids. They never accept a free-typed name. */ +export const ACCESSORIES: readonly Accessory[] = [ + { id: "twig-cap", slot: "head", rarity: "common", name: "Twig cap", tint: "#8d6b45" }, + { id: "wool-scarf", slot: "neck", rarity: "common", name: "Wool scarf", tint: "#c46b5a" }, + { id: "linen-wrap", slot: "body", rarity: "common", name: "Linen wrap", tint: "#d9d0bf" }, + { id: "satchel", slot: "back", rarity: "common", name: "Little satchel", tint: "#6e5344" }, + { id: "mud-boots", slot: "feet", rarity: "common", name: "Mud boots", tint: "#5c5148" }, + { id: "sprout-crown", slot: "head", rarity: "uncommon", name: "Sprout crown", tint: "#6ea86a" }, + { id: "river-beads", slot: "neck", rarity: "uncommon", name: "River beads", tint: "#6aa0c8" }, + { id: "patched-coat", slot: "body", rarity: "rare", name: "Patched coat", tint: "#3f6ea8" }, + { id: "moon-cape", slot: "back", rarity: "epic", name: "Moon cape", tint: "#7a5ab8" }, + { id: "star-slippers", slot: "feet", rarity: "mythic", name: "Star slippers", tint: "#e0b15a" }, +]; + +const byId = new Map(ACCESSORIES.map((item) => [item.id, item])); + +export function accessory(id: string) { + return byId.get(id) ?? null; +} + +export function isSlot(value: string): value is Slot { + return (SLOTS as readonly string[]).includes(value); +} + +/** Forage only ever finds a common piece, and often finds nothing. Rarer ids are for future minigames. */ +export function foundWhileForaging(challengeId: string) { + const bytes = blake3(new TextEncoder().encode(`boing.pets:find:${challengeId}`)); + if (bytes[0] > 110) return null; + const commons = ACCESSORIES.filter((item) => item.rarity === "common"); + return commons[bytes[1] % commons.length]?.id ?? null; +} + +export function rarityRank(rarity: Rarity) { + return RARITIES.indexOf(rarity); +} diff --git a/tests/api.test.ts b/tests/api.test.ts index c04575f..cb735b7 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -82,6 +82,12 @@ beforeEach(async () => { "utf8", ), ); + db.exec( + readFileSync( + new NodeURL("../migrations/0002_yard.sql", import.meta.url), + "utf8", + ), + ); const adapter = { prepare: (sql: string) => new Statement(db, sql), batch: async (statements: Statement[]) => { diff --git a/tests/hosting.test.ts b/tests/hosting.test.ts new file mode 100644 index 0000000..7ad01dd --- /dev/null +++ b/tests/hosting.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { request, type IncomingHttpHeaders } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { databaseMode, postgresConnectionString, toPg } from "../server/db"; +import { openSqlite } from "../server/sqlite"; +import { startServer } from "../server/index"; + +let close: (() => Promise) | undefined; +afterEach(async () => { + await close?.(); + close = undefined; +}); + +function raw( + port: number, + method: string, + path: string, + headers: Record, + body?: string, +) { + return new Promise<{ + status: number; + body: string; + headers: IncomingHttpHeaders; + }>((resolve, reject) => { + const req = request( + { hostname: "127.0.0.1", port, path, method, headers }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => + resolve({ + status: res.statusCode || 0, + body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers, + }), + ); + }, + ); + req.on("error", reject); + if (body) req.end(body); + else req.end(); + }); +} + +describe("Cursor Apps hosting", () => { + it("selects Postgres from the injected URL and keeps sqlite opt-in", () => { + expect(databaseMode({})).toBe("missing"); + expect(databaseMode({ PETS_DB: "sqlite" })).toBe("sqlite"); + expect( + databaseMode({ + DATABASE_URL: "postgres://db.example/pets", + PETS_DB: "sqlite", + }), + ).toBe("postgres"); + expect(databaseMode({ POSTGRES_URL: "postgres://db.example/pets" })).toBe( + "postgres", + ); + expect( + postgresConnectionString({ + DATABASE_URL: "postgres://pooled.example/pets", + DATABASE_URL_UNPOOLED: "postgres://direct.example/pets", + }), + ).toBe("postgres://direct.example/pets"); + expect(toPg("SELECT * FROM pets WHERE owner=? AND day=?")).toBe( + "SELECT * FROM pets WHERE owner=$1 AND day=$2", + ); + expect(toPg("SELECT '?'")).toBe("SELECT '?'"); + }); + + it("serves the client and same-origin API, and refuses another origin", async () => { + const directory = mkdtempSync(join(tmpdir(), "pets-")); + writeFileSync(join(directory, "index.html"), "Boing Pets"); + const { db } = await openSqlite(); + const server = await startServer({ + port: 0, + host: "127.0.0.1", + database: db, + staticRoot: directory, + }); + close = server.close; + const page = await raw(server.port, "GET", "/", {}); + expect(page.status).toBe(200); + expect(page.body).toContain("Boing Pets"); + expect(page.headers["content-security-policy"]).toContain( + "frame-ancestors", + ); + expect(page.headers["x-frame-options"]).toBeUndefined(); + const pasture = await raw(server.port, "GET", "/api/pasture", {}); + expect(pasture.status).toBe(200); + expect(JSON.parse(pasture.body)).toMatchObject({ pets: [], next: null }); + const blocked = await raw( + server.port, + "POST", + "/api/challenge", + { origin: "https://evil.example", "content-type": "application/json" }, + JSON.stringify({ owner: "0x" + "11".repeat(32), action: "hatch" }), + ); + expect(blocked.status).toBe(403); + const published = await raw( + server.port, + "POST", + "/api/challenge", + { + host: "pets.cursor.app", + "x-forwarded-host": "pets.cursor.app", + "x-forwarded-proto": "https", + origin: "https://pets.cursor.app", + "content-type": "application/json", + }, + JSON.stringify({ owner: "0x" + "11".repeat(32), action: "hatch" }), + ); + expect(published.status).toBe(200); + const secret = await raw(server.port, "GET", "/../package.json", {}); + expect(secret.status).not.toBe(200); + expect(secret.body).not.toContain("boing-pets"); + }); +}); diff --git a/tests/neon.test.ts b/tests/neon.test.ts new file mode 100644 index 0000000..7f1d611 --- /dev/null +++ b/tests/neon.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { openPostgres, postgresConnectionString } from "../server/db"; + +const url = postgresConnectionString(); +const live = Boolean(url && process.env.NEON_PROJECT_ID); +let close: (() => Promise | void) | undefined; + +afterEach(async () => { + await close?.(); + close = undefined; +}); + +describe.skipIf(!live)("Neon pasture", () => { + it("migrates and reads the shared database", async () => { + const opened = await openPostgres(url); + close = opened.close; + const row = await opened.db + .prepare("SELECT COUNT(*) AS n FROM pets") + .first<{ n: number }>(); + expect(row?.n).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/tests/postgres.test.ts b/tests/postgres.test.ts new file mode 100644 index 0000000..ed28304 --- /dev/null +++ b/tests/postgres.test.ts @@ -0,0 +1,213 @@ +import { PGlite } from "@electric-sql/pglite"; +import * as ed from "@noble/ed25519"; +import { blake3 } from "@noble/hashes/blake3"; +import { bytesToHex } from "@noble/hashes/utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import app from "../worker/index"; +import { + createDb, + migrateSql, + normalizeRow, + toPg, + type Driver, + type PetsDb, +} from "../server/db"; +import { postgresSchema } from "../server/postgres-schema"; + +const origin = "http://localhost:5177"; +const tx = "0x" + "ab".repeat(32); +const fixtureKey = new Uint8Array(32).fill(7); +let owner = ""; +let amount = ""; +let rpcHash = tx; +let chainId = 6913; +let receiptSuccess = true; +let pending = false; +let pgdb: PGlite; +let db: PetsDb; + +function driverFor(database: PGlite): Driver { + const query: Driver["query"] = async (sql, params) => { + const result = await database.query(toPg(sql), params); + return result.rows.map((row) => + normalizeRow(row as Record), + ); + }; + return { + query, + transaction: async (run) => { + await database.exec("BEGIN"); + try { + await run(query); + await database.exec("COMMIT"); + } catch (error) { + await database.exec("ROLLBACK"); + throw error; + } + }, + }; +} + +async function request(path: string, body?: unknown, from = origin) { + return app.fetch( + new Request( + origin + path, + body === undefined + ? undefined + : { + method: "POST", + headers: { origin: from, "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ), + { + DB: db as unknown as D1Database, + ASSETS: { + fetch: async () => new Response("assets"), + } as unknown as Fetcher, + RPC_URL: "https://rpc.example", + APP_ORIGIN: origin, + }, + ); +} + +beforeEach(async () => { + owner = "0x" + bytesToHex(await ed.getPublicKeyAsync(fixtureKey)); + chainId = 6913; + rpcHash = tx; + receiptSuccess = true; + pending = false; + amount = ""; + pgdb = new PGlite(); + const driver = driverFor(pgdb); + await migrateSql(driver, postgresSchema); + db = createDb(driver); + vi.stubGlobal( + "fetch", + vi.fn(async (_url: unknown, init: RequestInit) => { + const { method } = JSON.parse(init.body as string) as { method: string }; + const receipt = { + tx_id: rpcHash, + block_height: 101, + tx_index: 0, + success: receiptSuccess, + }; + const result = + method === "boing_getNetworkInfo" + ? { chain_id: chainId } + : method === "boing_chainHeight" + ? 100 + : method === "boing_getTransactionReceipt" + ? pending + ? null + : receipt + : { + header: { height: 101 }, + transactions: [ + { + sender: owner, + payload: { Transfer: { to: owner, amount } }, + }, + ], + receipts: [receipt], + }; + return Response.json({ jsonrpc: "2.0", id: 1, result }); + }), + ); +}); +afterEach(async () => { + vi.unstubAllGlobals(); + await pgdb.close(); +}); + +describe("Postgres pasture", () => { + it("keeps hatch, feed, and check-in constraints", async () => { + const hatch = await request("/api/challenge", { owner, action: "hatch" }); + expect(hatch.status).toBe(200); + const challenge = (await hatch.json()) as { + id: string; + amount: string; + message: string; + }; + amount = challenge.amount; + expect(typeof challenge.amount).toBe("string"); + expect( + (await request("/api/confirm", { id: challenge.id, tx_hash: tx })).status, + ).toBe(200); + expect( + (await request("/api/confirm", { id: challenge.id, tx_hash: tx })).status, + ).toBe(200); + expect( + (await request("/api/challenge", { owner, action: "hatch" })).status, + ).toBe(409); + const stored = await pgdb.query<{ amount: string }>( + "SELECT amount FROM challenges WHERE id = $1", + [challenge.id], + ); + expect(stored.rows[0]?.amount).toBe(challenge.amount); + const feed = await request("/api/challenge", { owner, action: "feed" }); + const fed = (await feed.json()) as { id: string; amount: string }; + amount = fed.amount; + rpcHash = "0x" + "cd".repeat(32); + expect( + (await request("/api/confirm", { id: fed.id, tx_hash: rpcHash })).status, + ).toBe(200); + expect( + (await request("/api/challenge", { owner, action: "feed" })).status, + ).toBe(409); + const hello = await request("/api/challenge", { owner, action: "checkin" }); + const checkin = (await hello.json()) as { id: string; message: string }; + const signature = + "0x" + + bytesToHex( + await ed.signAsync( + blake3(new TextEncoder().encode(checkin.message)), + fixtureKey, + ), + ); + expect( + (await request("/api/confirm", { id: checkin.id, signature })).status, + ).toBe(200); + const pet = await request("/api/pet?owner=" + owner); + expect(await pet.json()).toMatchObject({ + pet: { + state: { streak: 1, fedToday: true, checkedIn: true, hunger: 100 }, + }, + }); + }); + + it("does not keep a pet when the care event rolls back", async () => { + const hatch = await request("/api/challenge", { owner, action: "hatch" }); + const challenge = (await hatch.json()) as { id: string; amount: string }; + amount = challenge.amount; + await pgdb.exec(` + CREATE FUNCTION pets_fail_event() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'fixture'; + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER fail_event BEFORE INSERT ON events FOR EACH ROW EXECUTE FUNCTION pets_fail_event(); + `); + expect( + (await request("/api/confirm", { id: challenge.id, tx_hash: tx })).status, + ).toBe(409); + const pets = await pgdb.query<{ n: number }>( + "SELECT COUNT(*)::int AS n FROM pets", + ); + expect(Number(pets.rows[0]?.n)).toBe(0); + }); + + it("rejects a receipt from another chain without writing a pet", async () => { + const hatch = await request("/api/challenge", { owner, action: "hatch" }); + const challenge = (await hatch.json()) as { id: string; amount: string }; + amount = challenge.amount; + chainId = 6914; + expect( + (await request("/api/confirm", { id: challenge.id, tx_hash: tx })).status, + ).toBe(503); + const pets = await pgdb.query<{ n: number }>( + "SELECT COUNT(*)::int AS n FROM pets", + ); + expect(Number(pets.rows[0]?.n)).toBe(0); + }); +}); diff --git a/tests/tycoon.test.ts b/tests/tycoon.test.ts new file mode 100644 index 0000000..7746af4 --- /dev/null +++ b/tests/tycoon.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { decodeReferenceNftCalldata } from "@boing-network/boing-sdk"; +import { dailyRate, evolutionStage, previewKibble, yardSpot } from "../src/tycoon"; +import { mintPetTx, petTokenId, verifyMintTx } from "../src/nft"; +import { DAY } from "../src/core"; + +describe("tycoon yard", () => { + it("lets a fed buddy earn, and only sells snacks when the belly is full", () => { + const fed = { hunger: 100, happiness: 100, status: "bouncy", buildings: [] as const, nftStaked: false, bonded: false }; + const stand = dailyRate({ ...fed, buildings: ["stand"] }); + const hungryStand = dailyRate({ ...fed, hunger: 0, status: "stray", buildings: ["stand"] }); + expect(stand).toBeGreaterThan(dailyRate(fed)); + expect(stand).toBeGreaterThan(hungryStand); + const toy = dailyRate({ ...fed, buildings: ["toy"] }); + const glumToy = dailyRate({ ...fed, happiness: 0, buildings: ["toy"] }); + expect(toy).toBeGreaterThan(glumToy); + const home = dailyRate({ ...fed, buildings: ["pen"] }); + const stray = dailyRate({ hunger: 0, happiness: 10, status: "stray", buildings: ["pen"], nftStaked: false, bonded: false }); + expect(home).toBeGreaterThan(stray); + }); + it("grows a buddy from care, age, and practice", () => { + expect(evolutionStage(0, 0, 0)).toBe("hatchling"); + expect(evolutionStage(DAY * 4, 3, 2)).toBe("grown"); + expect(evolutionStage(DAY * 10, 6, 4)).toBe("elder"); + }); + it("caps offline earnings at three days", () => { + const preview = previewKibble({ stored: 5, settledAt: 0, now: 10 * DAY, ratePerDay: 48 }); + expect(preview.kibble).toBe(5 + 48 * 3); + }); + it("gives every wallet a stable yard spot", () => { + const owner = "0x" + "ab".repeat(32); + expect(yardSpot(owner)).toEqual(yardSpot(owner)); + }); + it("builds a reference NFT mint the server can verify", () => { + const owner = "0x" + "11".repeat(32); + const contract = "0x" + "22".repeat(32); + const tx = mintPetTx(owner, contract); + const decoded = decodeReferenceNftCalldata(tx.calldata); + expect(decoded?.selector).toBe(6); + verifyMintTx( + { sender: owner, payload: { ContractCall: { contract, calldata: tx.calldata } } }, + owner, + contract, + petTokenId(owner), + ); + }); +}); \ No newline at end of file diff --git a/tests/wardrobe.test.ts b/tests/wardrobe.test.ts new file mode 100644 index 0000000..96278fe --- /dev/null +++ b/tests/wardrobe.test.ts @@ -0,0 +1,56 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { URL as NodeURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { ACCESSORIES, accessory, foundWhileForaging, rarityRank } from "../src/wardrobe"; +import { grantPiece } from "../worker/wardrobe"; +import { YardError } from "../worker/errors"; + +describe("pet wardrobe", () => { + it("keeps every catalog piece on one clothing slot", () => { + const ids = new Set(ACCESSORIES.map((item) => item.id)); + expect(ids.size).toBe(ACCESSORIES.length); + for (const item of ACCESSORIES) expect(accessory(item.id)?.slot).toBe(item.slot); + expect(rarityRank("mythic")).toBeGreaterThan(rarityRank("common")); + }); + it("lets forage find only common clothes", () => { + const found = new Set(); + for (let i = 0; i < 40; i += 1) { + const item = foundWhileForaging("challenge-" + i); + if (!item) continue; + found.add(item); + expect(accessory(item)?.rarity).toBe("common"); + } + expect(found.size).toBeGreaterThan(0); + }); + it("grants a chosen piece once and keeps forage off the rare catalog", async () => { + const db = new DatabaseSync(":memory:"); + db.exec(readFileSync(new NodeURL("../migrations/0002_yard.sql", import.meta.url), "utf8")); + const env = { + DB: { + prepare: (sql: string) => { + const statement = { + values: [] as unknown[], + bind(...values: unknown[]) { + statement.values = values; + return statement; + }, + async run() { + db.prepare(sql).run(...(statement.values as never[])); + }, + }; + return statement; + }, + }, + } as unknown as Env; + const owner = "0x" + "ab".repeat(32); + await grantPiece(env, owner, "moon-cape", "minigame", 1, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + await grantPiece(env, owner, "moon-cape", "minigame", 2, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + expect(db.prepare("SELECT item,source FROM wardrobe").all()).toEqual([{ item: "moon-cape", source: "minigame" }]); + await expect(grantPiece(env, owner, "moon-cape", "forage", 3, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")).rejects.toBeInstanceOf( + YardError, + ); + expect(db.prepare("SELECT COUNT(*) AS n FROM wardrobe").get()).toEqual({ n: 1 }); + db.close(); + }); +}); diff --git a/tests/yard.test.ts b/tests/yard.test.ts new file mode 100644 index 0000000..22ec2d9 --- /dev/null +++ b/tests/yard.test.ts @@ -0,0 +1,172 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { URL as NodeURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import * as ed from "@noble/ed25519"; +import { blake3 } from "@noble/hashes/blake3"; +import { bytesToHex } from "@noble/hashes/utils"; +import app from "../worker/index"; + +function memoryEnv(owner: string) { + const db = new DatabaseSync(":memory:"); + db.exec(readFileSync(new NodeURL("../migrations/0001_pasture.sql", import.meta.url), "utf8")); + db.exec(readFileSync(new NodeURL("../migrations/0002_yard.sql", import.meta.url), "utf8")); + db.prepare("INSERT INTO pets(owner,hatch_tx,created_at) VALUES(?,?,?)").run(owner, "0x" + "ab".repeat(32), Date.now() - 86_400_000); + const adapter = { + prepare: (sql: string) => { + const statement = { + sql, + values: [] as unknown[], + bind(...values: unknown[]) { + statement.values = values; + return statement; + }, + async first() { + return db.prepare(sql).get(...(statement.values as never[])) ?? null; + }, + async all() { + return { results: db.prepare(sql).all(...(statement.values as never[])) }; + }, + async run() { + return db.prepare(sql).run(...(statement.values as never[])); + }, + }; + return statement; + }, + batch: async (statements: Array<{ sql: string; values: unknown[] }>) => { + db.exec("BEGIN"); + try { + for (const statement of statements) db.prepare(statement.sql).run(...(statement.values as never[])); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + }; + const env = { + DB: adapter, + ASSETS: { fetch: async () => new Response("assets") }, + RPC_URL: "https://rpc.example", + APP_ORIGIN: "http://localhost:5177", + }; + return { db, env }; +} + +async function sign(key: Uint8Array, message: string) { + return "0x" + bytesToHex(await ed.signAsync(blake3(new TextEncoder().encode(message)), key)); +} + +describe("yard actions", () => { + it("collects kibble with a signature and spends it on a pen", async () => { + const key = new Uint8Array(32).fill(4); + const owner = "0x" + bytesToHex(await ed.getPublicKeyAsync(key)); + const { db, env } = memoryEnv(owner); + const challenge = await app.fetch( + new Request("http://localhost:5177/api/challenge", { + method: "POST", + headers: { origin: env.APP_ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ owner, action: "collect" }), + }), + env as unknown as Env, + ); + expect(challenge.status).toBe(200); + const body = (await challenge.json()) as { id: string; message: string }; + const confirmed = await app.fetch( + new Request("http://localhost:5177/api/confirm", { + method: "POST", + headers: { origin: env.APP_ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ id: body.id, signature: await sign(key, body.message) }), + }), + env as unknown as Env, + ); + expect(confirmed.status).toBe(200); + const yard = await app.fetch(new Request("http://localhost:5177/api/yard?viewer=" + owner), env as unknown as Env); + const view = (await yard.json()) as { me: { kibble: number } }; + expect(view.me.kibble).toBeGreaterThan(0); + db.close(); + }); + + it("equips owned clothes and refuses a client-chosen catalog id", async () => { + const key = new Uint8Array(32).fill(9); + const owner = "0x" + bytesToHex(await ed.getPublicKeyAsync(key)); + const { db, env } = memoryEnv(owner); + const headers = { origin: env.APP_ORIGIN, "content-type": "application/json" }; + const grant = await app.fetch( + new Request("http://localhost:5177/api/challenge", { + method: "POST", + headers, + body: JSON.stringify({ owner, action: "grant", item: "star-slippers" }), + }), + env as unknown as Env, + ); + expect(grant.status).toBe(400); + const forged = await app.fetch( + new Request("http://localhost:5177/api/challenge", { + method: "POST", + headers, + body: JSON.stringify({ owner, action: "equip", piece: "moon-cape" }), + }), + env as unknown as Env, + ); + expect(forged.status).toBe(404); + expect(db.prepare("SELECT COUNT(*) AS n FROM wardrobe").get()).toEqual({ n: 0 }); + db.prepare("INSERT INTO wardrobe(id,owner,item,source,created_at) VALUES(?,?,?,?,?)").run( + "piece-coat", + owner, + "patched-coat", + "minigame", + Date.now(), + ); + const equip = await app.fetch( + new Request("http://localhost:5177/api/challenge", { + method: "POST", + headers, + body: JSON.stringify({ owner, action: "equip", piece: "piece-coat" }), + }), + env as unknown as Env, + ); + expect(equip.status).toBe(200); + const issued = (await equip.json()) as { id: string; message: string; detail: { slot: string } }; + expect(issued.detail.slot).toBe("body"); + const worn = await app.fetch( + new Request("http://localhost:5177/api/confirm", { + method: "POST", + headers, + body: JSON.stringify({ id: issued.id, signature: await sign(key, issued.message) }), + }), + env as unknown as Env, + ); + expect(worn.status).toBe(200); + const yard = await app.fetch(new Request("http://localhost:5177/api/yard?viewer=" + owner), env as unknown as Env); + const view = (await yard.json()) as { + me: { wardrobe: { id: string; rarity: string; equipped: boolean }[] }; + pets: { wear: { item: string; slot: string }[] }[]; + }; + expect(view.me.wardrobe).toEqual([expect.objectContaining({ id: "piece-coat", rarity: "rare", equipped: true })]); + expect(view.pets[0]?.wear).toEqual([expect.objectContaining({ item: "patched-coat", slot: "body" })]); + const takeoff = await app.fetch( + new Request("http://localhost:5177/api/challenge", { + method: "POST", + headers, + body: JSON.stringify({ owner, action: "unequip", slot: "body" }), + }), + env as unknown as Env, + ); + const off = (await takeoff.json()) as { id: string; message: string }; + const cleared = await app.fetch( + new Request("http://localhost:5177/api/confirm", { + method: "POST", + headers, + body: JSON.stringify({ id: off.id, signature: await sign(key, off.message) }), + }), + env as unknown as Env, + ); + expect(cleared.status).toBe(200); + const after = await app.fetch(new Request("http://localhost:5177/api/yard?viewer=" + owner), env as unknown as Env); + const bare = (await after.json()) as { pets: { wear: unknown[] }[]; me: { wardrobe: { equipped: boolean }[] } }; + expect(bare.pets[0]?.wear).toEqual([]); + expect(bare.me.wardrobe[0]?.equipped).toBe(false); + db.close(); + }); +}); diff --git a/tsconfig.worker.json b/tsconfig.worker.json index f00eb1f..23a7a1d 100644 --- a/tsconfig.worker.json +++ b/tsconfig.worker.json @@ -4,5 +4,5 @@ "lib": ["ES2022"], "types": ["@cloudflare/workers-types", "node"] }, - "include": ["worker", "src/core.ts", "tests"] + "include": ["worker", "server", "src/core.ts", "tests"] } diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..6647128 --- /dev/null +++ b/vercel.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "npm run build", + "outputDirectory": "dist/client", + "rewrites": [ + { "source": "/((?!api/)(?!assets/).*)", "destination": "/index.html" } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://boing.express; img-src 'self' data:; frame-ancestors 'self' https://cursor.com https://*.cursor.com https://cursor.app https://*.cursor.app; base-uri 'self'; form-action 'self'" + } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] +} diff --git a/vite.config.ts b/vite.config.ts index 400f93b..47997c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,4 +5,9 @@ export default defineConfig({ strictPort: true, proxy: { "/api": "http://127.0.0.1:8787" }, }, + build: { + outDir: "dist/client", + emptyOutDir: true, + sourcemap: false, + }, }); diff --git a/worker/errors.ts b/worker/errors.ts new file mode 100644 index 0000000..7f4a5d3 --- /dev/null +++ b/worker/errors.ts @@ -0,0 +1,8 @@ +export class YardError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} diff --git a/worker/index.ts b/worker/index.ts index 52821a1..730b936 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -13,6 +13,9 @@ import { type Pet, } from "../src/core"; import { verifyProof, type Receipt, type RpcBlock } from "./proof"; +import { confirmYard, yardPost, yardState } from "./yard"; +import { YardError } from "./errors"; +import { isYardAction } from "../src/tycoon"; interface Challenge { id: string; owner: string; @@ -120,6 +123,10 @@ async function api(req: Request, env: Env): Promise { now, }); } + if (req.method === "GET" && url.pathname === "/api/yard") { + const viewer = url.searchParams.get("viewer"); + return yardState(env, viewer ? hex32(viewer) : null, now); + } if (req.method === "GET" && url.pathname === "/api/pet") { const owner = hex32(url.searchParams.get("owner")); const pet = await env.DB.prepare("SELECT * FROM pets WHERE owner=?") @@ -157,9 +164,14 @@ async function api(req: Request, env: Env): Promise { } catch { throw new HttpError(400, "Invalid JSON"); } + if (url.pathname === "/api/presence" || url.pathname === "/api/challenge") { + const yard = await yardPost(env, body, url.pathname, now); + if (yard) return yard; + } if (url.pathname === "/api/challenge") { const owner = hex32(body.owner), action = body.action as Action; + if (isYardAction(String(action))) throw new HttpError(400, "Unknown care action"); if (!["hatch", "feed", "checkin"].includes(action)) throw new HttpError(400, "Unknown care action"); const pet = await env.DB.prepare("SELECT * FROM pets WHERE owner=?") @@ -232,6 +244,8 @@ async function api(req: Request, env: Env): Promise { }); } if (url.pathname === "/api/confirm") { + const yard = await confirmYard(env, body, now); + if (yard) return yard; if (typeof body.id !== "string" || body.id.length > 64) throw new HttpError(400, "Invalid challenge"); const c = await env.DB.prepare("SELECT * FROM challenges WHERE id=?") @@ -322,13 +336,13 @@ export default { return json( { error: - e instanceof HttpError + e instanceof HttpError || e instanceof YardError ? e.message : e instanceof Error && e.message.startsWith("Expected a 32-byte") ? e.message : "Request could not be processed", }, - e instanceof HttpError + e instanceof HttpError || e instanceof YardError ? e.status : e instanceof Error && e.message.startsWith("Expected a 32-byte") ? 400 diff --git a/worker/proof.ts b/worker/proof.ts index 73a8d86..8395cda 100644 --- a/worker/proof.ts +++ b/worker/proof.ts @@ -20,12 +20,12 @@ function record(value: unknown): Record { throw new Error("Malformed RPC proof"); return value as Record; } -export function verifyProof( +export function includedTx( hash: string, c: ProofChallenge, r: Receipt | null, b: RpcBlock | null, -): void { +): Record { if (!r) throw new Error( "Transaction is not included yet. Wait and retry confirmation.", @@ -44,7 +44,15 @@ export function verifyProof( const br = b.receipts?.[r.tx_index]; if (!br || br.success !== true || hex32(br.tx_id) !== hash) throw new Error("Block receipt does not match"); - const tx = record(b.transactions[r.tx_index]); + return record(b.transactions[r.tx_index]); +} +export function verifyProof( + hash: string, + c: ProofChallenge, + r: Receipt | null, + b: RpcBlock | null, +): void { + const tx = includedTx(hash, c, r, b); if (hex32(tx.sender) !== c.owner) throw new Error("Transaction belongs to another wallet"); const transfer = record(record(tx.payload).Transfer); diff --git a/worker/wardrobe.ts b/worker/wardrobe.ts new file mode 100644 index 0000000..78123e7 --- /dev/null +++ b/worker/wardrobe.ts @@ -0,0 +1,21 @@ +import { accessory } from "../src/wardrobe"; +import { YardError } from "./errors"; + +/** Future minigames call this with a server-chosen catalog id. The browser never picks one. */ +export function pieceInsert(env: Env, owner: string, itemId: string, source: string, now: number, id: string) { + const item = accessory(itemId); + if (!item) throw new YardError(400, "Unknown accessory"); + if (!/^[a-z0-9-]{3,32}$/.test(source)) throw new YardError(400, "Unknown minigame"); + if (source === "forage" && item.rarity !== "common") throw new YardError(400, "Forage only finds common clothes"); + if (!/^[0-9a-f-]{8,64}$/i.test(id)) throw new YardError(400, "Bad piece id"); + return env.DB.prepare( + "INSERT INTO wardrobe(id,owner,item,source,created_at) VALUES(?,?,?,?,?) ON CONFLICT(id) DO NOTHING", + ).bind(id, owner, item.id, source, now); +} + +export async function grantPiece(env: Env, owner: string, itemId: string, source: string, now: number, id = crypto.randomUUID()) { + await pieceInsert(env, owner, itemId, source, now, id).run(); + const item = accessory(itemId); + if (!item) throw new YardError(400, "Unknown accessory"); + return { id, item }; +} diff --git a/worker/yard.ts b/worker/yard.ts new file mode 100644 index 0000000..c764100 --- /dev/null +++ b/worker/yard.ts @@ -0,0 +1,544 @@ +import * as ed from "@noble/ed25519"; +import { blake3 } from "@noble/hashes/blake3"; +import { hexToBytes } from "@noble/hashes/utils"; +import { DAY, dayOf, hex32, state, traits, type Pet } from "../src/core"; +import { + bondTx, + deployKennelTx, + mintPetTx, + ownerStorageKey, + petMetadataHash, + petTokenId, + predictedKennel, + verifyBondTx, + verifyDeployTx, + verifyMintTx, +} from "../src/nft"; +import { + BOND_AMOUNT, + BUILDINGS, + FIELD_RADIUS, + buildingOf, + dailyRate, + evolutionStage, + isYardAction, + online, + previewKibble, + yardSpot, + type Building, + type Skill, + type YardAction, +} from "../src/tycoon"; +import { includedTx, type Receipt, type RpcBlock } from "./proof"; +import { YardError } from "./errors"; +import { pieceInsert } from "./wardrobe"; +import { accessory, foundWhileForaging, isSlot } from "../src/wardrobe"; + +type Detail = { + contract?: string; + tokenId?: string; + nonce?: number; + kind?: Building; + skill?: Skill; + piece?: string; + slot?: string; +}; + +async function rpc(env: Env, method: string, params: unknown[] = []): Promise { + try { + const response = await fetch(env.RPC_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error("HTTP error"); + const body = (await response.json()) as { result: T; error?: unknown }; + if (body.error || body.result === undefined) throw new Error("RPC rejected request"); + return body.result; + } catch (error) { + if (error instanceof YardError) throw error; + throw new YardError(503, "Testnet RPC is unavailable. No pet state was changed; retry later."); + } +} + +const json = (data: unknown, status = 200) => + Response.json(data, { status, headers: { "cache-control": "no-store" } }); + +async function kennel(env: Env) { + return env.DB.prepare("SELECT contract,deployer,created_at FROM kennel WHERE id=1").first<{ + contract: string; + deployer: string; + created_at: number; + }>(); +} + +async function buildings(env: Env, owner: string) { + const rows = await env.DB.prepare("SELECT kind FROM stalls WHERE owner=?").bind(owner).all<{ kind: Building }>(); + return rows.results.map((row) => row.kind); +} + +async function skillBook(env: Env, owner: string, now: number) { + const rows = await env.DB.prepare("SELECT skill,points,last_day FROM skills WHERE owner=?").bind(owner).all<{ + skill: Skill; + points: number; + last_day: number; + }>(); + const book = { forage: 0, greet: 0, play: 0 }; + const practiced: Skill[] = []; + let sum = 0; + for (const row of rows.results) { + book[row.skill] = row.points; + sum += row.points; + if (row.last_day === dayOf(now)) practiced.push(row.skill); + } + return { book, sum, practiced }; +} + +async function rateFor( + env: Env, + owner: string, + petState: { hunger: number; happiness: number; status: string }, + now: number, +) { + const nft = await env.DB.prepare("SELECT staked FROM pet_nfts WHERE owner=?").bind(owner).first<{ staked: number }>(); + const yard = await env.DB.prepare("SELECT bonded FROM yards WHERE owner=?").bind(owner).first<{ bonded: number }>(); + const skills = await skillBook(env, owner, now); + return dailyRate({ + hunger: petState.hunger, + happiness: petState.happiness, + status: petState.status, + buildings: await buildings(env, owner), + nftStaked: nft?.staked === 1, + bonded: yard?.bonded === 1, + ...skills.book, + }); +} + +export async function yardState(env: Env, viewer: string | null, now: number) { + const pets = await env.DB.prepare("SELECT * FROM pets ORDER BY created_at DESC LIMIT 24").all(); + const seen = await env.DB.prepare("SELECT owner,seen_at,x,z FROM presence").all<{ + owner: string; + seen_at: number; + x: number | null; + z: number | null; + }>(); + const here = new Map(seen.results.map((row) => [row.owner, row])); + const collection = await kennel(env); + const roster = await Promise.all( + pets.results.map(async (pet) => { + const events = await env.DB.prepare("SELECT action,created_at FROM events WHERE owner=? ORDER BY created_at") + .bind(pet.owner) + .all<{ action: "hatch" | "feed" | "checkin"; created_at: number }>(); + const petState = state(pet, events.results, now); + const nft = await env.DB.prepare("SELECT staked,token_id,contract FROM pet_nfts WHERE owner=?") + .bind(pet.owner) + .first<{ staked: number; token_id: string; contract: string }>(); + const skills = await skillBook(env, pet.owner, now); + const place = here.get(pet.owner); + const worn = await env.DB.prepare( + "SELECT e.slot,e.piece,w.item FROM equipped e JOIN wardrobe w ON w.id=e.piece WHERE e.owner=?", + ) + .bind(pet.owner) + .all<{ slot: string; piece: string; item: string }>(); + return { + owner: pet.owner, + hatch_tx: pet.hatch_tx, + traits: traits(pet.owner, pet.hatch_tx), + state: petState, + stage: evolutionStage(now - pet.created_at, events.results.length, skills.sum), + wandered: place?.x !== null && place?.x !== undefined && place.z !== null && place.z !== undefined, + spot: + place?.x !== null && place?.x !== undefined && place.z !== null && place.z !== undefined + ? { x: place.x, z: place.z } + : yardSpot(pet.owner), + online: online(place?.seen_at ?? null, now), + nft: nft ? { tokenId: nft.token_id, contract: nft.contract, staked: nft.staked === 1 } : null, + wear: worn.results.map((row) => ({ slot: row.slot, piece: row.piece, item: row.item })), + buildings: await buildings(env, pet.owner), + }; + }), + ); + let me = null; + if (viewer) { + const mine = roster.find((pet) => pet.owner === viewer); + const row = await env.DB.prepare("SELECT kibble,settled_at,bonded FROM yards WHERE owner=?").bind(viewer).first<{ + kibble: number; + settled_at: number; + bonded: number; + }>(); + const pet = await env.DB.prepare("SELECT created_at FROM pets WHERE owner=?").bind(viewer).first<{ created_at: number }>(); + if (pet && mine) { + const preview = previewKibble({ + stored: row?.kibble ?? 0, + settledAt: row?.settled_at ?? pet.created_at, + now, + ratePerDay: await rateFor(env, viewer, mine.state, now), + }); + const skills = await skillBook(env, viewer, now); + me = { + kibble: preview.kibble, + rate: await rateFor(env, viewer, mine.state, now), + stage: mine.stage, + skills: skills.book, + practiced: skills.practiced, + minted: Boolean(mine.nft), + staked: mine.nft?.staked === true, + bonded: row?.bonded === 1, + buildings: mine.buildings, + wardrobe: ( + await env.DB.prepare("SELECT id,item,source FROM wardrobe WHERE owner=? ORDER BY created_at").bind(viewer).all<{ + id: string; + item: string; + source: string; + }>() + ).results.map((row) => { + const item = accessory(row.item); + return { + id: row.id, + item: row.item, + source: row.source, + name: item?.name ?? row.item, + slot: item?.slot ?? "body", + rarity: item?.rarity ?? "common", + equipped: mine.wear.some((piece) => piece.piece === row.id), + }; + }), + }; + } + } + return json({ now, collection: collection?.contract ?? null, me, pets: roster }); +} + +async function issue(env: Env, owner: string, action: YardAction, now: number, body: Record = {}) { + const pet = await env.DB.prepare("SELECT * FROM pets WHERE owner=?").bind(owner).first(); + if (!pet) throw new YardError(409, "Hatch your buddy before opening the yard"); + const recent = await env.DB.prepare("SELECT COUNT(*) AS n FROM yard_challenges WHERE owner=? AND created_at>?") + .bind(owner, now - 60_000) + .first<{ n: number }>(); + if ((recent?.n || 0) >= 6) throw new YardError(429, "Too many requests. Try again in a minute."); + const home = await kennel(env); + const events = await env.DB.prepare("SELECT action,created_at FROM events WHERE owner=? ORDER BY created_at") + .bind(owner) + .all<{ action: "hatch" | "feed" | "checkin"; created_at: number }>(); + const petState = state(pet, events.results, now); + let height = 0; + let detail: Detail = {}; + let transaction: Record | null = null; + const chain = action === "deploy_nft" || action === "mint_nft" || action === "bond" || action === "unbond"; + if (chain) { + const info = await rpc<{ chain_id: number }>(env, "boing_getNetworkInfo"); + if (info.chain_id !== 6913) throw new YardError(503, "Pets only supports Boing testnet 6913"); + height = await rpc(env, "boing_chainHeight"); + } + if (action === "deploy_nft") { + if (home) throw new YardError(409, "The pet NFT kennel is already open"); + const account = await rpc<{ nonce: number }>(env, "boing_getAccount", [owner]); + detail = { nonce: account.nonce, contract: predictedKennel(owner, BigInt(account.nonce)) }; + transaction = deployKennelTx(); + } + if (action === "mint_nft") { + if (!home) throw new YardError(409, "The kennel NFT collection has not been deployed yet"); + const existing = await env.DB.prepare("SELECT owner FROM pet_nfts WHERE owner=?").bind(owner).first(); + if (existing) throw new YardError(409, "Your pet NFT is already minted"); + detail = { contract: home.contract, tokenId: petTokenId(owner) }; + transaction = mintPetTx(owner, home.contract); + } + if (action === "bond" || action === "unbond") { + const minted = await env.DB.prepare("SELECT owner FROM pet_nfts WHERE owner=?").bind(owner).first(); + if (!minted) throw new YardError(409, "Mint your pet NFT before bonding BOING"); + transaction = bondTx(action === "unbond"); + } + if (action === "stake_nft" || action === "unstake_nft") { + const minted = await env.DB.prepare("SELECT token_id,contract,staked FROM pet_nfts WHERE owner=?") + .bind(owner) + .first<{ token_id: string; contract: string; staked: number }>(); + if (!minted) throw new YardError(409, "Mint your pet NFT before staking it"); + if (action === "stake_nft" && minted.staked === 1) throw new YardError(409, "Your pet NFT is already staked"); + if (action === "unstake_nft" && minted.staked !== 1) throw new YardError(409, "Your pet NFT is not staked"); + await assertNftOwner(env, minted.contract, minted.token_id, owner); + detail = { contract: minted.contract, tokenId: minted.token_id }; + } + const skill = action.startsWith("train_") ? (action.slice("train_".length) as Skill) : null; + if (skill) { + const known = await env.DB.prepare("SELECT last_day FROM skills WHERE owner=? AND skill=?").bind(owner, skill).first<{ + last_day: number; + }>(); + if (known?.last_day === dayOf(now)) throw new YardError(409, "Already practiced today. Come back after midnight UTC."); + detail = { skill }; + } + if (action === "equip") { + const piece = typeof body.piece === "string" ? body.piece : ""; + const owned = await env.DB.prepare("SELECT id,item FROM wardrobe WHERE id=? AND owner=?").bind(piece, owner).first<{ + id: string; + item: string; + }>(); + const item = owned ? accessory(owned.item) : null; + if (!owned || !item) throw new YardError(404, "You do not own that accessory"); + detail = { piece: owned.id, slot: item.slot }; + } + if (action === "unequip") { + const slot = typeof body.slot === "string" ? body.slot : ""; + if (!isSlot(slot)) throw new YardError(400, "Unknown clothing slot"); + detail = { slot }; + } + const kind = buildingOf(action); + if (kind) { + const owned = await env.DB.prepare("SELECT kind FROM stalls WHERE owner=? AND kind=?").bind(owner, kind).first(); + if (owned) throw new YardError(409, `${BUILDINGS[kind].label} is already built`); + detail = { kind }; + } + const id = crypto.randomUUID(); + const expires = now + 60 * 60_000; + const message = `Boing Pets v1\nOrigin: ${env.APP_ORIGIN}\nChain: 6913\nAction: ${action}\nOwner: ${owner}\nNonce: ${id}\nExpires: ${expires}`; + await env.DB.prepare( + "INSERT INTO yard_challenges(id,owner,action,message,amount,created_at,expires_at,min_height,detail) VALUES(?,?,?,?,?,?,?,?,?)", + ) + .bind(id, owner, action, message, BOND_AMOUNT, now, expires, height, JSON.stringify(detail)) + .run(); + return json({ + id, + owner, + action, + message, + amount: BOND_AMOUNT, + expires_at: expires, + transaction, + detail, + metadata: action === "mint_nft" ? petMetadataHash(owner) : null, + status: petState.status, + }); +} + +async function assertNftOwner(env: Env, contract: string, tokenId: string, owner: string) { + const stored = await rpc<{ value: string }>(env, "boing_getContractStorage", [contract, ownerStorageKey(tokenId)]); + if (hex32(stored.value) !== hex32(owner)) throw new YardError(409, "Your wallet no longer holds this pet NFT"); +} + +async function settle(env: Env, owner: string, now: number) { + const pet = await env.DB.prepare("SELECT * FROM pets WHERE owner=?").bind(owner).first(); + if (!pet) throw new YardError(409, "Hatch your buddy first"); + const events = await env.DB.prepare("SELECT action,created_at FROM events WHERE owner=? ORDER BY created_at") + .bind(owner) + .all<{ action: "hatch" | "feed" | "checkin"; created_at: number }>(); + const petState = state(pet, events.results, now); + const row = await env.DB.prepare("SELECT kibble,settled_at,bonded FROM yards WHERE owner=?").bind(owner).first<{ + kibble: number; + settled_at: number; + bonded: number; + }>(); + const preview = previewKibble({ + stored: row?.kibble ?? 0, + settledAt: row?.settled_at ?? pet.created_at, + now, + ratePerDay: await rateFor(env, owner, petState, now), + }); + if (row) { + await env.DB.prepare("UPDATE yards SET kibble=?,settled_at=? WHERE owner=?").bind(preview.kibble, now, owner).run(); + } else { + await env.DB.prepare("INSERT INTO yards(owner,kibble,settled_at,bonded) VALUES(?,?,?,0)") + .bind(owner, preview.kibble, now) + .run(); + } + return preview.kibble; +} + +export async function confirmYard(env: Env, body: Record, now: number) { + if (typeof body.id !== "string") return null; + const row = await env.DB.prepare("SELECT * FROM yard_challenges WHERE id=?").bind(body.id).first<{ + id: string; + owner: string; + action: YardAction; + message: string; + amount: string; + expires_at: number; + min_height: number; + detail: string; + }>(); + if (!row) return null; + if (row.expires_at < now) throw new YardError(410, "Challenge expired. Start a new yard action."); + const done = await env.DB.prepare("SELECT id FROM yard_events WHERE id=?").bind(row.id).first(); + if (done) return json({ ok: true, alreadyConfirmed: true }); + const detail = JSON.parse(row.detail) as Detail; + const signed = [ + "stake_nft", + "unstake_nft", + "collect", + "buy_pen", + "buy_stand", + "buy_toy", + "train_forage", + "train_greet", + "train_play", + "wander", + "equip", + "unequip", + ].includes(row.action); + let hash: string | null = null; + if (signed) { + if (typeof body.signature !== "string" || !/^(0x)?[0-9a-f]{128}$/i.test(body.signature)) + throw new YardError(400, "Invalid signature format"); + const ok = await ed.verifyAsync( + hexToBytes(body.signature.replace(/^0x/i, "")), + blake3(new TextEncoder().encode(row.message)), + hexToBytes(row.owner.slice(2)), + ); + if (!ok) throw new YardError(403, "Signature does not match this wallet and challenge"); + } else { + hash = hex32(body.tx_hash); + const info = await rpc<{ chain_id: number }>(env, "boing_getNetworkInfo"); + if (info.chain_id !== 6913) throw new YardError(503, "Pets only supports Boing testnet 6913"); + const receipt = await rpc(env, "boing_getTransactionReceipt", [hash]); + const block = receipt + ? await rpc(env, "boing_getBlockByHeight", [receipt.block_height, true, false]) + : null; + let tx: Record; + try { + tx = includedTx(hash, { owner: row.owner, amount: row.amount, min_height: row.min_height }, receipt, block); + } catch (error) { + throw new YardError(receipt ? 400 : 425, error instanceof Error ? error.message : "Invalid transaction"); + } + if (row.action === "deploy_nft") verifyDeployTx(tx, row.owner, detail.nonce ?? -1, detail.contract ?? ""); + if (row.action === "mint_nft") verifyMintTx(tx, row.owner, detail.contract ?? "", detail.tokenId ?? ""); + if (row.action === "bond" || row.action === "unbond") verifyBondTx(tx, row.owner, row.action === "unbond"); + } + const statements = [ + env.DB.prepare("INSERT INTO yard_events(id,owner,action,tx_hash,created_at,day) VALUES(?,?,?,?,?,?)").bind( + row.id, + row.owner, + row.action, + hash, + now, + dayOf(now), + ), + ]; + if (row.action === "deploy_nft") { + statements.push( + env.DB.prepare("INSERT INTO kennel(id,contract,deployer,created_at) VALUES(1,?,?,?)").bind( + detail.contract, + row.owner, + now, + ), + ); + } + if (row.action === "mint_nft") { + const stored = await rpc<{ value: string }>(env, "boing_getContractStorage", [ + detail.contract, + ownerStorageKey(detail.tokenId ?? ""), + ]); + if (hex32(stored.value) !== row.owner) throw new YardError(409, "Mint did not give this wallet the pet NFT"); + statements.push( + env.DB.prepare("INSERT INTO pet_nfts(owner,token_id,contract,mint_tx,staked) VALUES(?,?,?,?,0)").bind( + row.owner, + detail.tokenId, + detail.contract, + hash, + ), + ); + } + if (row.action === "stake_nft" || row.action === "unstake_nft") { + await assertNftOwner(env, detail.contract ?? "", detail.tokenId ?? "", row.owner); + statements.push( + env.DB.prepare("UPDATE pet_nfts SET staked=? WHERE owner=?").bind(row.action === "stake_nft" ? 1 : 0, row.owner), + ); + } + if (row.action === "bond" || row.action === "unbond") { + const existing = await env.DB.prepare("SELECT owner FROM yards WHERE owner=?").bind(row.owner).first(); + if (!existing) { + statements.push( + env.DB.prepare("INSERT INTO yards(owner,kibble,settled_at,bonded) VALUES(?,?,?,?)").bind( + row.owner, + 0, + now, + row.action === "bond" ? 1 : 0, + ), + ); + } else { + statements.push( + env.DB.prepare("UPDATE yards SET bonded=? WHERE owner=?").bind(row.action === "bond" ? 1 : 0, row.owner), + ); + } + } + const kind = buildingOf(row.action); + if (kind) { + const balance = await settle(env, row.owner, now); + if (balance < BUILDINGS[kind].cost) throw new YardError(409, `Need ${BUILDINGS[kind].cost} kibble for ${BUILDINGS[kind].label}`); + statements.push( + env.DB.prepare("UPDATE yards SET kibble=kibble-? WHERE owner=?").bind(BUILDINGS[kind].cost, row.owner), + env.DB.prepare("INSERT INTO stalls(owner,kind,created_at) VALUES(?,?,?)").bind(row.owner, kind, now), + ); + } + if (row.action === "collect") await settle(env, row.owner, now); + if (detail.skill === "forage") { + const found = foundWhileForaging(row.id); + if (found) { + statements.push(pieceInsert(env, row.owner, found, "forage", now, row.id)); + detail.piece = row.id; + } + } + if (detail.skill) { + statements.push( + env.DB.prepare( + "INSERT INTO skills(owner,skill,points,last_day) VALUES(?,?,1,?) ON CONFLICT(owner,skill) DO UPDATE SET points=skills.points+1,last_day=excluded.last_day", + ).bind(row.owner, detail.skill, dayOf(now)), + ); + } + if (row.action === "equip" && detail.piece && detail.slot) { + statements.push( + env.DB.prepare("DELETE FROM equipped WHERE owner=? AND (slot=? OR piece=?)").bind(row.owner, detail.slot, detail.piece), + env.DB.prepare("INSERT INTO equipped(owner,slot,piece) VALUES(?,?,?)").bind(row.owner, detail.slot, detail.piece), + ); + } + if (row.action === "unequip" && detail.slot) { + statements.push(env.DB.prepare("DELETE FROM equipped WHERE owner=? AND slot=?").bind(row.owner, detail.slot)); + } + if (row.action === "wander") { + statements.push( + env.DB.prepare( + "INSERT INTO presence(owner,seen_at,pass,pass_until) VALUES(?,?,?,?) ON CONFLICT(owner) DO UPDATE SET seen_at=excluded.seen_at,pass=excluded.pass,pass_until=excluded.pass_until", + ).bind(row.owner, now, row.id, now + DAY), + ); + } + try { + await env.DB.batch(statements); + } catch { + throw new YardError(409, "That yard action was already recorded"); + } + return json({ ok: true, pass: row.action === "wander" ? row.id : undefined }); +} + +export async function yardPost(env: Env, body: Record, path: string, now: number) { + if (path === "/api/presence") { + const owner = hex32(body.owner); + const pet = await env.DB.prepare("SELECT owner FROM pets WHERE owner=?").bind(owner).first(); + if (!pet) throw new YardError(404, "No buddy for that wallet"); + const x = Number(body.x); + const z = Number(body.z); + const moving = Number.isFinite(x) && Number.isFinite(z); + if (moving) { + if (Math.hypot(x, z) > FIELD_RADIUS) throw new YardError(400, "That spot is outside the pasture"); + const pass = await env.DB.prepare("SELECT pass,pass_until FROM presence WHERE owner=?").bind(owner).first<{ + pass: string | null; + pass_until: number | null; + }>(); + if (!pass?.pass || pass.pass !== body.pass || (pass.pass_until ?? 0) < now) + throw new YardError(403, "Sign a short wander pass before walking the pasture"); + await env.DB.prepare( + "UPDATE presence SET seen_at=?,x=?,z=? WHERE owner=?", + ) + .bind(now, x, z, owner) + .run(); + } else { + await env.DB.prepare( + "INSERT INTO presence(owner,seen_at) VALUES(?,?) ON CONFLICT(owner) DO UPDATE SET seen_at=excluded.seen_at", + ) + .bind(owner, now) + .run(); + } + return json({ ok: true, now }); + } + if (path !== "/api/challenge") return null; + const action = String(body.action); + if (!isYardAction(action)) return null; + return issue(env, hex32(body.owner), action, now, body); +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 9753de3..8390d06 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -6,7 +6,7 @@ "workers_dev": false, "preview_urls": false, "assets": { - "directory": "./dist", + "directory": "./dist/client", "binding": "ASSETS", "run_worker_first": ["/api/*"], },