diff --git a/.gitignore b/.gitignore index b7b6e6a..785d4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ legacy .netlify deno.lock version.json +data diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md new file mode 100644 index 0000000..93d23eb --- /dev/null +++ b/adapters/claude-code/README.md @@ -0,0 +1,60 @@ +# Claude Code agent-event hook + +This adapter forwards Claude Code `SessionStart`, `Notification`, and `Stop` hooks into the Lunar Pup agent harness endpoint. + +## Environment + +Set the same shared secret on the game server and the hook process: + +```sh +export AGENT_EVENT_TOKEN="replace-with-a-long-random-token" +export AGENT_EVENT_ENDPOINT="http://localhost:3001/agent/event" +``` + +`AGENT_EVENT_ENDPOINT` defaults to `http://localhost:3001/agent/event` when omitted. + +## Claude Code hooks + +Add this to your Claude Code `settings.json` hooks block. Use an absolute path if Claude Code runs outside this repository. + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ], + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ] + } +} +``` + +The hook reads Claude Code's JSON hook payload from stdin, converts it to the shared `AgentEvent` contract, and POSTs it with `Authorization: Bearer $AGENT_EVENT_TOKEN`. diff --git a/adapters/claude-code/agent-event-hook.ts b/adapters/claude-code/agent-event-hook.ts new file mode 100755 index 0000000..478e88e --- /dev/null +++ b/adapters/claude-code/agent-event-hook.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +import { validateAgentEvent, type AgentEvent, type AgentEventType } from '../../src/contracts/agentEvents.ts'; + +interface ClaudeHookPayload { + hook_event_name?: string; + session_id?: string; + cwd?: string; + transcript_path?: string; + message?: string; + stop_hook_active?: boolean; +} + +const eventTypeByHook = new Map([ + ['SessionStart', 'agent_session_start'], + ['Notification', 'agent_needs_input'], + ['Stop', 'agent_done'], +]); + +async function readHookPayload(): Promise { + const input = await new Response(Bun.stdin.stream()).text(); + if (!input.trim()) return {}; + const value = JSON.parse(input) as unknown; + if (!value || typeof value !== 'object') return {}; + + const payload: ClaudeHookPayload = {}; + if ('hook_event_name' in value && typeof value.hook_event_name === 'string') payload.hook_event_name = value.hook_event_name; + if ('session_id' in value && typeof value.session_id === 'string') payload.session_id = value.session_id; + if ('cwd' in value && typeof value.cwd === 'string') payload.cwd = value.cwd; + if ('transcript_path' in value && typeof value.transcript_path === 'string') payload.transcript_path = value.transcript_path; + if ('message' in value && typeof value.message === 'string') payload.message = value.message; + if ('stop_hook_active' in value && typeof value.stop_hook_active === 'boolean') payload.stop_hook_active = value.stop_hook_active; + return payload; +} + +function eventTypeFor(payload: ClaudeHookPayload): AgentEventType { + const hookEvent = payload.hook_event_name ?? ''; + return eventTypeByHook.get(hookEvent) ?? 'agent_status'; +} + +function projectName(cwd: string | undefined): string { + if (!cwd) return 'unknown-project'; + const parts = cwd.split(/[\\/]+/).filter(Boolean); + return parts.at(-1) ?? cwd; +} + +function messageFor(payload: ClaudeHookPayload, type: AgentEventType): string { + if (payload.message) return payload.message; + if (type === 'agent_session_start') return 'Claude Code session started'; + if (type === 'agent_needs_input') return 'Claude Code needs input'; + if (type === 'agent_done') return 'Claude Code session stopped'; + return 'Claude Code status update'; +} + +async function main(): Promise { + const endpoint = process.env.AGENT_EVENT_ENDPOINT ?? 'http://localhost:3001/agent/event'; + const token = process.env.AGENT_EVENT_TOKEN; + if (!token) throw new Error('AGENT_EVENT_TOKEN is required'); + + const payload = await readHookPayload(); + const type = eventTypeFor(payload); + const event: AgentEvent = { + type, + harness: 'claude-code', + sessionId: payload.session_id ?? crypto.randomUUID(), + project: projectName(payload.cwd), + message: messageFor(payload, type), + timestamp: new Date().toISOString(), + }; + + const valid = validateAgentEvent(event); + if (!valid.ok) throw new Error(valid.error); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(valid.value), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`agent event POST failed: ${response.status} ${text}`); + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/bun.lock b/bun.lock index 7751a3b..644a1fe 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,17 @@ "": { "name": "lunarpup", "dependencies": { + "@metaplex-foundation/mpl-token-metadata": "^3.4.0", + "@metaplex-foundation/umi": "^1.5.1", + "@metaplex-foundation/umi-bundle-defaults": "^1.5.1", + "@metaplex-foundation/umi-signer-wallet-adapters": "^1.5.1", "@netlify/blobs": "^10.7.0", "@netlify/functions": "^5.1.2", + "@solana/spl-token": "^0.4.15", + "@solana/web3.js": "^1.98.4", + "bs58": "^6.0.0", "three": "^0.185.1", + "tweetnacl": "^1.0.3", }, "devDependencies": { "@netlify/edge-functions": "^3.0.3", @@ -20,12 +28,54 @@ }, }, "packages": { + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@envelop/instrumentation": ["@envelop/instrumentation@1.0.0", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" } }, "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw=="], "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], + "@metaplex-foundation/mpl-token-metadata": ["@metaplex-foundation/mpl-token-metadata@3.4.0", "", { "dependencies": { "@metaplex-foundation/mpl-toolbox": "^0.10.0" }, "peerDependencies": { "@metaplex-foundation/umi": ">= 0.8.2 <= 1" } }, "sha512-AxBAYCK73JWxY3g9//z/C9krkR0t1orXZDknUPS4+GjwGH2vgPfsk04yfZ31Htka2AdS9YE/3wH7sMUBHKn9Rg=="], + + "@metaplex-foundation/mpl-toolbox": ["@metaplex-foundation/mpl-toolbox@0.10.0", "", { "peerDependencies": { "@metaplex-foundation/umi": ">= 0.8.2 <= 1" } }, "sha512-84KD1L5cFyw5xnntHwL4uPwfcrkKSiwuDeypiVr92qCUFuF3ZENa2zlFVPu+pQcjTlod2LmEX3MhBmNjRMpdKg=="], + + "@metaplex-foundation/umi": ["@metaplex-foundation/umi@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-options": "^1.5.1", "@metaplex-foundation/umi-public-keys": "^1.5.1", "@metaplex-foundation/umi-serializers": "^1.5.1" } }, "sha512-ONRv5a0kv+23AMlR8oyFBHnjVg3o3N8pUfFcV4gzbg6OgZf87zHsPWBfED3OTJqx267v1bEn6d6DABXNFq9Z3A=="], + + "@metaplex-foundation/umi-bundle-defaults": ["@metaplex-foundation/umi-bundle-defaults@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-downloader-http": "^1.5.1", "@metaplex-foundation/umi-eddsa-web3js": "^1.5.1", "@metaplex-foundation/umi-http-fetch": "^1.5.1", "@metaplex-foundation/umi-program-repository": "^1.5.1", "@metaplex-foundation/umi-rpc-chunk-get-accounts": "^1.5.1", "@metaplex-foundation/umi-rpc-web3js": "^1.5.1", "@metaplex-foundation/umi-serializer-data-view": "^1.5.1", "@metaplex-foundation/umi-transaction-factory-web3js": "^1.5.1" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-7qoXenAkQbcj468HGAeLZDyg3eEhcS9rWAnGqjnKgWOlL1czL2Qwho0FEtqOv57IHwAJSTpbHbcvABmdpTjjdw=="], + + "@metaplex-foundation/umi-downloader-http": ["@metaplex-foundation/umi-downloader-http@1.5.1", "", { "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1" } }, "sha512-1s9gSTaDtwELyxBRE6Wmdr3xWeb4Z1uU04dj3Hg8VU+TN6/3wchh93+rIGZT5D3zzdh4+yPxdYV+4ZEr3T5glQ=="], + + "@metaplex-foundation/umi-eddsa-web3js": ["@metaplex-foundation/umi-eddsa-web3js@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-web3js-adapters": "^1.5.1", "@noble/curves": "^1.0.0", "yaml": "^2.7.0" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-ZlzmXXAa1Ujk00G5TmqXM81J25+k/8sqt0zxBUlLTUSOxzlhxhlUKdErIhpHazbKq+eGck+Onm17oAwVKdKAcw=="], + + "@metaplex-foundation/umi-http-fetch": ["@metaplex-foundation/umi-http-fetch@1.5.1", "", { "dependencies": { "node-fetch": "^2.6.7" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1" } }, "sha512-AOjZJo3Ua4a2FvgA85x5f0TkMSb+13Ao3uLIQ9FbScV42kqZnDox8KjJ7tKm1ZtYDlCYD0pSFMKPOC9NPDnHDg=="], + + "@metaplex-foundation/umi-options": ["@metaplex-foundation/umi-options@1.5.1", "", {}, "sha512-ZE6uXgFA3rElFq4gJxZM2diAqZdFqL65bOnAggwdnnei5XXRzFyNF16wYSqlHnPLvG6ohRHWiXww8d2Mb83xFg=="], + + "@metaplex-foundation/umi-program-repository": ["@metaplex-foundation/umi-program-repository@1.5.1", "", { "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1" } }, "sha512-E5W0IjwFgDGuBTshISbbEh/s8deqxcOzzEjOOlYdMXnevVsfNLwBBIAY4NPJg3v5vpFlKODwUGB5BxCUVthzJg=="], + + "@metaplex-foundation/umi-public-keys": ["@metaplex-foundation/umi-public-keys@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-serializers-encodings": "^1.5.1" } }, "sha512-joTnI1mRtYRfIaTo98uaYRjBPszsdyHuq0vvd6QbSX+MPvu3enkWi+UicuykEc3VXd5tcGdNMiGSx4jgXG6pkw=="], + + "@metaplex-foundation/umi-rpc-chunk-get-accounts": ["@metaplex-foundation/umi-rpc-chunk-get-accounts@1.5.1", "", { "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1" } }, "sha512-3dnGobT1Xwul7fXzQr8660UHSnFOCWEed4T449oNekrVsHp2o00fdOqjXwo11DYhS1rjm+gbzRSazRKb62uF2Q=="], + + "@metaplex-foundation/umi-rpc-web3js": ["@metaplex-foundation/umi-rpc-web3js@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-web3js-adapters": "^1.5.1" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-CxHyruh2gW2b/ZOwHFFtooOgtu9hBrOJTd3HUMtD/jpaturApa3itsL/zNt4K34tELzVIUL7N78LDjNpzbu9Kw=="], + + "@metaplex-foundation/umi-serializer-data-view": ["@metaplex-foundation/umi-serializer-data-view@1.5.1", "", { "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1" } }, "sha512-9Wxqk3bGVJ0xNmHhHrOUhdu/90Q1IT3FZRZN4eGckb0sf7Bgls7kBTkFfgXFmUh2VBnE0GnnncXeHKtop5RSFA=="], + + "@metaplex-foundation/umi-serializers": ["@metaplex-foundation/umi-serializers@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-options": "^1.5.1", "@metaplex-foundation/umi-public-keys": "^1.5.1", "@metaplex-foundation/umi-serializers-core": "^1.5.1", "@metaplex-foundation/umi-serializers-encodings": "^1.5.1", "@metaplex-foundation/umi-serializers-numbers": "^1.5.1" } }, "sha512-scXciBylbJ4iwfxOF1Xx2XiBzoYUD8fSKWTsMal5Rj1hMRDe6b2XZcsBOjio61iAr8aTtFPmKpqxeBdLwmQ0ZQ=="], + + "@metaplex-foundation/umi-serializers-core": ["@metaplex-foundation/umi-serializers-core@1.5.1", "", {}, "sha512-6nYsbTCLq421x7JT1B3/iNgPpSARj/wL9naoKbOreHrk2ip/4R7vQstVRMl0Gx+Hv2tHnEIbFo3JBtWyC377Qw=="], + + "@metaplex-foundation/umi-serializers-encodings": ["@metaplex-foundation/umi-serializers-encodings@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-serializers-core": "^1.5.1" } }, "sha512-cVvwWmREE/Pmvjvsd50F18P53HDT0vzZECD6uYWIVzxgwpOiRDFu6r/vGbweomHoWzfTvuU6hiKuKv2KsOoXQA=="], + + "@metaplex-foundation/umi-serializers-numbers": ["@metaplex-foundation/umi-serializers-numbers@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-serializers-core": "^1.5.1" } }, "sha512-7DVF1VJIdT44Pe6qWKaqGu4YVgE10OeLMYpm7C16SujSBgQGB/I2bh8NBifyH2R3oHhoyfE9qgIKB3dgRazN6A=="], + + "@metaplex-foundation/umi-signer-wallet-adapters": ["@metaplex-foundation/umi-signer-wallet-adapters@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-web3js-adapters": "^1.5.1" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-H9ZqXWOFmlgVCex/kR/+xHSJOomNpIqgwJJ+j9ihmn55TUeYKIJfymLoyn+MjDZBlnbaIwos+xhw8m+3W9QcJQ=="], + + "@metaplex-foundation/umi-transaction-factory-web3js": ["@metaplex-foundation/umi-transaction-factory-web3js@1.5.1", "", { "dependencies": { "@metaplex-foundation/umi-web3js-adapters": "^1.5.1" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-g4NfvtnmXtH1Q/Y9LdCsFtDRHQZmZWW7uKz+N9a+IVsJTTvpWFALMHm66dFDQGa0ExAYxAj7j6uZH2qDn0zarA=="], + + "@metaplex-foundation/umi-web3js-adapters": ["@metaplex-foundation/umi-web3js-adapters@1.5.1", "", { "dependencies": { "buffer": "^6.0.3" }, "peerDependencies": { "@metaplex-foundation/umi": "^1.5.1", "@solana/web3.js": "^1.72.0" } }, "sha512-6W3JElD0B0EbgHofVKqk4PbP/JDrUHIKWciM7tEuXTDXbuXbSECDe7qlTU0JZXmVZNfYufI6FHnkCfPys2ZnIQ=="], + "@netlify/blobs": ["@netlify/blobs@10.7.9", "", { "dependencies": { "@netlify/dev-utils": "4.4.6", "@netlify/otel": "^6.0.3", "@netlify/runtime-utils": "2.3.0" } }, "sha512-NEM8aNAMZCCWBWymomMM/fn5wmPGyGE8pendgsSu5mL7UNz+aXqGcHS0MiHWU/osyg+geiZuS+Rx956SVrMM/w=="], "@netlify/dev-utils": ["@netlify/dev-utils@4.4.6", "", { "dependencies": { "@whatwg-node/server": "^0.10.0", "ansis": "^4.1.0", "atomically": "^2.0.3", "chokidar": "^4.0.1", "decache": "^4.6.2", "dettle": "^1.0.5", "dot-prop": "9.0.0", "empathic": "^2.0.0", "env-paths": "^3.0.0", "image-size": "^2.0.2", "js-image-generator": "^1.0.4", "parse-gitignore": "^2.0.0", "semver": "^7.7.2", "tmp-promise": "^3.0.3" } }, "sha512-P6X+xS3glvhiTX6AAocYvnZtqXtWhvxMX4AdPgv2iw6cXjGL2criUveX7bSjp6DiyHfvQpNdG0ZRpeBEVAFf1g=="], @@ -40,6 +90,10 @@ "@netlify/types": ["@netlify/types@2.8.0", "", {}, "sha512-8/g0Pt6y6wXj5Ia5eeYLiXhRfWeqZXGXpGFeCiiQdUOem+FPtXdA4+YdGxqzWc7D0AvptKSO01KGeeVWHSu8Kg=="], + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.217.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A=="], @@ -58,10 +112,40 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@solana/buffer-layout": ["@solana/buffer-layout@4.0.1", "", { "dependencies": { "buffer": "~6.0.3" } }, "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA=="], + + "@solana/buffer-layout-utils": ["@solana/buffer-layout-utils@0.3.0", "", { "dependencies": { "@solana/buffer-layout": "^4.0.0", "@solana/web3.js": "^1.32.0", "bigint-buffer": "^1.1.5", "bignumber.js": "^9.0.1" } }, "sha512-MuQOCC1j0np1xH9yAv0ZWWfwvr7Bt7Sz4LId11Wi4wDdAmJ+lobE+vHg/mZmGcihF0BIkqVBNxGmlv8QE5DrtA=="], + + "@solana/codecs": ["@solana/codecs@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-data-structures": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/codecs-strings": "2.0.0-rc.1", "@solana/options": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ=="], + + "@solana/codecs-core": ["@solana/codecs-core@2.3.0", "", { "dependencies": { "@solana/errors": "2.3.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw=="], + + "@solana/codecs-data-structures": ["@solana/codecs-data-structures@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog=="], + + "@solana/codecs-numbers": ["@solana/codecs-numbers@2.3.0", "", { "dependencies": { "@solana/codecs-core": "2.3.0", "@solana/errors": "2.3.0" }, "peerDependencies": { "typescript": ">=5.3.3" } }, "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg=="], + + "@solana/codecs-strings": ["@solana/codecs-strings@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "fastestsmallesttextencoderdecoder": "^1.0.22", "typescript": ">=5" } }, "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g=="], + + "@solana/errors": ["@solana/errors@2.3.0", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^14.0.0" }, "peerDependencies": { "typescript": ">=5.3.3" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ=="], + + "@solana/options": ["@solana/options@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/codecs-data-structures": "2.0.0-rc.1", "@solana/codecs-numbers": "2.0.0-rc.1", "@solana/codecs-strings": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA=="], + + "@solana/spl-token": ["@solana/spl-token@0.4.15", "", { "dependencies": { "@solana/buffer-layout": "^4.0.0", "@solana/buffer-layout-utils": "^0.3.0", "@solana/spl-token-group": "^0.0.7", "@solana/spl-token-metadata": "^0.1.6", "buffer": "^6.0.3" }, "peerDependencies": { "@solana/web3.js": "^1.95.5" } }, "sha512-3Lof3mNov8NVQ3PalIWb1Jgr/TZ6lYM+/sexv2TLqdhNFVth2OfWmH3d7QucgMjSbokkjNiNlRr6I8Fd269uaw=="], + + "@solana/spl-token-group": ["@solana/spl-token-group@0.0.7", "", { "dependencies": { "@solana/codecs": "2.0.0-rc.1" }, "peerDependencies": { "@solana/web3.js": "^1.95.3" } }, "sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug=="], + + "@solana/spl-token-metadata": ["@solana/spl-token-metadata@0.1.6", "", { "dependencies": { "@solana/codecs": "2.0.0-rc.1" }, "peerDependencies": { "@solana/web3.js": "^1.95.3" } }, "sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA=="], + + "@solana/web3.js": ["@solana/web3.js@1.98.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", "@noble/hashes": "^1.4.0", "@solana/buffer-layout": "^4.0.1", "@solana/codecs-numbers": "^2.1.0", "agentkeepalive": "^4.5.0", "bn.js": "^5.2.1", "borsh": "^0.7.0", "bs58": "^4.0.1", "buffer": "6.0.3", "fast-stable-stringify": "^1.0.0", "jayson": "^4.1.1", "node-fetch": "^2.7.0", "rpc-websockets": "^9.0.2", "superstruct": "^2.0.2" } }, "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw=="], + + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -70,6 +154,8 @@ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], + "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.13", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" } }, "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q=="], @@ -80,22 +166,50 @@ "@whatwg-node/server": ["@whatwg-node/server@0.10.18", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="], "atomically": ["atomically@2.1.1", "", { "dependencies": { "stubborn-fs": "^2.0.0", "when-exit": "^2.1.4" } }, "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ=="], + "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bigint-buffer": ["bigint-buffer@1.1.5", "", { "dependencies": { "bindings": "^1.3.0" } }, "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bn.js": ["bn.js@5.2.5", "", {}, "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg=="], + + "borsh": ["borsh@0.7.0", "", { "dependencies": { "bn.js": "^5.2.0", "bs58": "^4.0.0", "text-encoding-utf-8": "^1.0.2" } }, "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA=="], + + "bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "callsite": ["callsite@1.0.0", "", {}, "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decache": ["decache@4.6.2", "", { "dependencies": { "callsite": "^1.0.0" } }, "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw=="], + "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], + "dettle": ["dettle@1.0.5", "", {}, "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA=="], "dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], @@ -106,42 +220,86 @@ "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "es6-promisify": ["es6-promisify@5.0.0", "", { "dependencies": { "es6-promise": "^4.0.3" } }, "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], + + "fast-stable-stringify": ["fast-stable-stringify@1.0.0", "", {}, "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag=="], + + "fastestsmallesttextencoderdecoder": ["fastestsmallesttextencoderdecoder@1.0.22", "", {}, "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], "import-in-the-middle": ["import-in-the-middle@3.3.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA=="], + "isomorphic-ws": ["isomorphic-ws@4.0.1", "", { "peerDependencies": { "ws": "*" } }, "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="], + + "jayson": ["jayson@4.3.0", "", { "dependencies": { "@types/connect": "^3.4.33", "@types/node": "^12.12.54", "@types/ws": "^7.4.4", "commander": "^2.20.3", "delay": "^5.0.0", "es6-promisify": "^5.0.0", "eyes": "^0.1.8", "isomorphic-ws": "^4.0.1", "json-stringify-safe": "^5.0.1", "stream-json": "^1.9.1", "uuid": "^8.3.2", "ws": "^7.5.10" }, "bin": { "jayson": "bin/jayson.js" } }, "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "js-image-generator": ["js-image-generator@1.0.4", "", { "dependencies": { "jpeg-js": "^0.4.2" } }, "sha512-ckb7kyVojGAnArouVR+5lBIuwU1fcrn7E/YYSd0FK7oIngAkMmRvHASLro9Zt5SQdWToaI66NybG+OGxPw/HlQ=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "parse-gitignore": ["parse-gitignore@2.0.0", "", {}, "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "rpc-websockets": ["rpc-websockets@9.3.10", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/ws": "^8.2.2", "buffer": "^6.0.3", "eventemitter3": "^5.0.1", "ws": "^8.5.0" }, "optionalDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^6.0.0" } }, "sha512-QT5PQ6LiWhA5RCS93oWwgxU4XzQltkYm8C3aTmmKEgj0HolGRo3VbdzELw7CEV35l9T7Amha8Vnr4rCfSjVP+w=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], + + "stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="], + + "text-encoding-utf-8": ["text-encoding-utf-8@1.0.2", "", {}, "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg=="], + "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -150,6 +308,70 @@ "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "utf-8-validate": ["utf-8-validate@6.0.6", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], + + "ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "@solana/codecs/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-data-structures/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs-data-structures/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-data-structures/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/codecs-strings/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/codecs-strings/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/codecs-strings/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/errors/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@solana/options/@solana/codecs-core": ["@solana/codecs-core@2.0.0-rc.1", "", { "dependencies": { "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ=="], + + "@solana/options/@solana/codecs-numbers": ["@solana/codecs-numbers@2.0.0-rc.1", "", { "dependencies": { "@solana/codecs-core": "2.0.0-rc.1", "@solana/errors": "2.0.0-rc.1" }, "peerDependencies": { "typescript": ">=5" } }, "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ=="], + + "@solana/options/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/web3.js/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + + "borsh/bs58": ["bs58@4.0.1", "", { "dependencies": { "base-x": "^3.0.2" } }, "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw=="], + + "jayson/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + + "rpc-websockets/@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "rpc-websockets/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@solana/codecs-data-structures/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs-strings/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs/@solana/codecs-core/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/codecs/@solana/codecs-numbers/@solana/errors": ["@solana/errors@2.0.0-rc.1", "", { "dependencies": { "chalk": "^5.3.0", "commander": "^12.1.0" }, "peerDependencies": { "typescript": ">=5" }, "bin": { "errors": "bin/cli.mjs" } }, "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ=="], + + "@solana/options/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/web3.js/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + + "borsh/bs58/base-x": ["base-x@3.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA=="], + + "@solana/codecs/@solana/codecs-core/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "@solana/codecs/@solana/codecs-numbers/@solana/errors/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8755352 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/setup.ts"] diff --git a/content/cosmetics/comet-trail/definition.json b/content/cosmetics/comet-trail/definition.json new file mode 100644 index 0000000..be4a065 --- /dev/null +++ b/content/cosmetics/comet-trail/definition.json @@ -0,0 +1,17 @@ +{ + "id": "comet-trail", + "slot": "trail", + "visual": { + "colors": [ + "#4cc9f0", + "#ffffffcc" + ], + "particles": { + "count": 36, + "size": 0.08, + "lifetime": 0.55, + "emissionRate": 20 + } + }, + "rarity": "rare" +} diff --git a/content/cosmetics/comet-trail/manifest.json b/content/cosmetics/comet-trail/manifest.json new file mode 100644 index 0000000..b888d05 --- /dev/null +++ b/content/cosmetics/comet-trail/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "1f0e8018379928ea714d6f1bedcb09f659927c13af9b1b19a51c6a189c45cfec", + "kind": "cosmetic", + "version": "1.0.0", + "author": "Moon Kennel", + "displayName": "Comet Trail", + "assetRefs": [ + { + "name": "definition", + "uri": "definition.json", + "sha256": "b270a5c0a3a1295bc83546d6f5eb4455322c33a0f4214a8af87797dbde5d4e75", + "mediaType": "application/json" + } + ], + "metadata": { + "price": "140", + "slot": "trail", + "rarity": "rare" + } +} diff --git a/content/cosmetics/legendary-nebula/definition.json b/content/cosmetics/legendary-nebula/definition.json new file mode 100644 index 0000000..2af7982 --- /dev/null +++ b/content/cosmetics/legendary-nebula/definition.json @@ -0,0 +1,28 @@ +{ + "id": "legendary-nebula", + "slot": "aura", + "visual": { + "colors": [ + "#7209b7", + "#ffd166cc", + "#4cc9f0cc" + ], + "mesh": { + "shape": "sphere", + "scale": [ + 1.55, + 0.16, + 1.55 + ], + "roughness": 0.15, + "metalness": 0.6 + }, + "particles": { + "count": 44, + "size": 0.07, + "lifetime": 1.2, + "emissionRate": 26 + } + }, + "rarity": "legendary" +} diff --git a/content/cosmetics/legendary-nebula/manifest.json b/content/cosmetics/legendary-nebula/manifest.json new file mode 100644 index 0000000..5d86b41 --- /dev/null +++ b/content/cosmetics/legendary-nebula/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "cf8edaabe0dcb8bfb26263d98720a9a6dcaa020661cc4a4355dd8c7805c406b5", + "kind": "cosmetic", + "version": "1.0.0", + "author": "Moon Kennel", + "displayName": "Legendary Nebula", + "assetRefs": [ + { + "name": "definition", + "uri": "definition.json", + "sha256": "bcafb5eb760fadf301c9efd3575f11ea58a79de7779a88724b38715c0b1c7a75", + "mediaType": "application/json" + } + ], + "metadata": { + "price": "500", + "slot": "aura", + "rarity": "legendary" + } +} diff --git a/content/cosmetics/mint-fur/definition.json b/content/cosmetics/mint-fur/definition.json new file mode 100644 index 0000000..a114af6 --- /dev/null +++ b/content/cosmetics/mint-fur/definition.json @@ -0,0 +1,21 @@ +{ + "id": "mint-fur", + "slot": "body", + "visual": { + "colors": [ + "#80ffdb", + "#caffbf" + ], + "mesh": { + "shape": "box", + "scale": [ + 1.0, + 1.0, + 1.0 + ], + "roughness": 0.8, + "metalness": 0.0 + } + }, + "rarity": "common" +} diff --git a/content/cosmetics/mint-fur/manifest.json b/content/cosmetics/mint-fur/manifest.json new file mode 100644 index 0000000..eda2fa2 --- /dev/null +++ b/content/cosmetics/mint-fur/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "efc1668de382942dc41f54e2dfc06aedfe17b2c08dd3e5efccfc91f96e468d9f", + "kind": "cosmetic", + "version": "1.0.0", + "author": "Moon Kennel", + "displayName": "Mint Fur", + "assetRefs": [ + { + "name": "definition", + "uri": "definition.json", + "sha256": "aff2e84aa411e3a7fcc85d9c5d86d88065e847bee4d4f9526b854c9f9a1efc26", + "mediaType": "application/json" + } + ], + "metadata": { + "price": "60", + "slot": "body", + "rarity": "common" + } +} diff --git a/content/cosmetics/moon-halo/definition.json b/content/cosmetics/moon-halo/definition.json new file mode 100644 index 0000000..2db53fe --- /dev/null +++ b/content/cosmetics/moon-halo/definition.json @@ -0,0 +1,27 @@ +{ + "id": "moon-halo", + "slot": "aura", + "visual": { + "colors": [ + "#b5179e", + "#f72585aa" + ], + "mesh": { + "shape": "sphere", + "scale": [ + 1.25, + 0.12, + 1.25 + ], + "roughness": 0.25, + "metalness": 0.35 + }, + "particles": { + "count": 18, + "size": 0.05, + "lifetime": 0.9, + "emissionRate": 10 + } + }, + "rarity": "epic" +} diff --git a/content/cosmetics/moon-halo/manifest.json b/content/cosmetics/moon-halo/manifest.json new file mode 100644 index 0000000..2351838 --- /dev/null +++ b/content/cosmetics/moon-halo/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "708f58bc2e141d33ec3d49e60c09ceada90d7eb62341a7a115335574de25562b", + "kind": "cosmetic", + "version": "1.0.0", + "author": "Moon Kennel", + "displayName": "Moon Halo", + "assetRefs": [ + { + "name": "definition", + "uri": "definition.json", + "sha256": "3610f6b8452844a068e85d9470b2ab65fb43ba944d7d357431a2f09561699983", + "mediaType": "application/json" + } + ], + "metadata": { + "price": "240", + "slot": "aura", + "rarity": "epic" + } +} diff --git a/content/cosmetics/sunrise-deck/definition.json b/content/cosmetics/sunrise-deck/definition.json new file mode 100644 index 0000000..4ba477b --- /dev/null +++ b/content/cosmetics/sunrise-deck/definition.json @@ -0,0 +1,21 @@ +{ + "id": "sunrise-deck", + "slot": "board", + "visual": { + "colors": [ + "#ff8c42", + "#ffd166" + ], + "mesh": { + "shape": "box", + "scale": [ + 1.0, + 1.0, + 1.0 + ], + "roughness": 0.45, + "metalness": 0.05 + } + }, + "rarity": "common" +} diff --git a/content/cosmetics/sunrise-deck/manifest.json b/content/cosmetics/sunrise-deck/manifest.json new file mode 100644 index 0000000..8d7d020 --- /dev/null +++ b/content/cosmetics/sunrise-deck/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "6a798f61d4fd759b34ac5d6b09f8e185fd80342b6d9b32237c16a02e246244a4", + "kind": "cosmetic", + "version": "1.0.0", + "author": "Moon Kennel", + "displayName": "Sunrise Deck", + "assetRefs": [ + { + "name": "definition", + "uri": "definition.json", + "sha256": "57db36d2aad6dbcc3aacd31294add9fc6151073ebc4f40326b591b100dfbd535", + "mediaType": "application/json" + } + ], + "metadata": { + "price": "75", + "slot": "board", + "rarity": "common" + } +} diff --git a/content/gamemodes/index.ts b/content/gamemodes/index.ts new file mode 100644 index 0000000..b96ffb2 --- /dev/null +++ b/content/gamemodes/index.ts @@ -0,0 +1,55 @@ +import type { GamemodePackageDefinition } from '../../src/modes/runtime.ts'; + +export const raceGamemodePackage: GamemodePackageDefinition = { + manifest: { + id: '412d11d15f0cbffba5c8f8b4455e20c190783a2d6022ffa915477481bfbc1a95', + kind: 'gamemode', + version: '1.0.0', + author: 'Lunar Pup', + displayName: 'Crater Circuit', + assetRefs: [], + metadata: { mode: 'race' }, + }, + params: { + type: 'race', + laps: 2, + startPosition: { x: 0, y: 8, z: 0 }, + checkpoints: [ + { id: 'start-finish', order: 0, position: { x: 0, y: 8, z: 0 }, radius: 18 }, + { id: 'ridge-turn', order: 1, position: { x: 120, y: 14, z: 80 }, radius: 22 }, + { id: 'crater-gate', order: 2, position: { x: 210, y: 12, z: -50 }, radius: 22 }, + { id: 'home-stretch', order: 3, position: { x: 70, y: 9, z: -135 }, radius: 22 }, + ], + }, +}; + +export const parkourGamemodePackage: GamemodePackageDefinition = { + manifest: { + id: 'dd2c367cb292d5921d905ce67156acb5a59cd9a8d16c9917b0592f38a003057d', + kind: 'gamemode', + version: '1.0.0', + author: 'Lunar Pup', + displayName: 'Low Orbit Parkour', + assetRefs: [], + metadata: { mode: 'parkour' }, + }, + params: { + type: 'parkour', + startPosition: { x: -80, y: 36, z: -40 }, + fallY: 4, + checkpoints: [ + { id: 'launch-pad', order: 0, position: { x: -80, y: 38, z: -40 }, radius: 12 }, + { id: 'satellite-hop', order: 1, position: { x: -42, y: 50, z: -18 }, radius: 10 }, + { id: 'comet-bridge', order: 2, position: { x: 5, y: 62, z: 8 }, radius: 10 }, + { id: 'moonshot', order: 3, position: { x: 52, y: 76, z: 34 }, radius: 12 }, + ], + platforms: [ + { id: 'launch-pad', position: { x: -80, y: 32, z: -40 }, size: { x: 34, y: 4, z: 28 } }, + { id: 'satellite-hop', position: { x: -42, y: 44, z: -18 }, size: { x: 26, y: 4, z: 22 } }, + { id: 'comet-bridge', position: { x: 5, y: 56, z: 8 }, size: { x: 30, y: 4, z: 20 } }, + { id: 'moonshot', position: { x: 52, y: 70, z: 34 }, size: { x: 36, y: 4, z: 28 } }, + ], + }, +}; + +export const gamemodePackages = [raceGamemodePackage, parkourGamemodePackage] as const; diff --git a/db/migrations/001_timescale_persistence.sql b/db/migrations/001_timescale_persistence.sql new file mode 100644 index 0000000..f2529de --- /dev/null +++ b/db/migrations/001_timescale_persistence.sql @@ -0,0 +1,84 @@ +CREATE EXTENSION IF NOT EXISTS timescaledb; + +CREATE TABLE IF NOT EXISTS agent_events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + type TEXT NOT NULL, + entity_id TEXT, + timestamp TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL, + PRIMARY KEY (timestamp, id) +); + +SELECT create_hypertable('agent_events', 'timestamp', if_not_exists => TRUE); + +CREATE INDEX IF NOT EXISTS idx_agent_events_type_time ON agent_events (type, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_agent_events_entity_time ON agent_events (entity_id, timestamp DESC); + +CREATE TABLE IF NOT EXISTS run_samples ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + gamemode_id TEXT NOT NULL, + account_id TEXT NOT NULL, + run_id TEXT NOT NULL, + completed_at TIMESTAMPTZ NOT NULL, + elapsed_ms INTEGER NOT NULL CHECK (elapsed_ms > 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (completed_at, id) +); + +SELECT create_hypertable('run_samples', 'completed_at', if_not_exists => TRUE); + +CREATE INDEX IF NOT EXISTS idx_run_samples_gamemode_time ON run_samples (gamemode_id, completed_at DESC); +CREATE INDEX IF NOT EXISTS idx_run_samples_gamemode_elapsed ON run_samples (gamemode_id, elapsed_ms ASC); + +CREATE TABLE IF NOT EXISTS economy_ledger ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + account_id TEXT NOT NULL, + event_type TEXT NOT NULL, + amount INTEGER, + cosmetic_id TEXT, + reason TEXT NOT NULL, + balance INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (created_at, id) +); + +SELECT create_hypertable('economy_ledger', 'created_at', if_not_exists => TRUE); + +CREATE INDEX IF NOT EXISTS idx_economy_ledger_account_time ON economy_ledger (account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_economy_ledger_type_time ON economy_ledger (event_type, created_at DESC); + +CREATE TABLE IF NOT EXISTS balances ( + account_id TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0) +); + +CREATE TABLE IF NOT EXISTS owned_items ( + account_id TEXT NOT NULL, + cosmetic_id TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, cosmetic_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS best_time_leaderboards_hourly +WITH (timescaledb.continuous) AS +SELECT + time_bucket('1 hour', completed_at) AS bucket, + gamemode_id, + min(elapsed_ms) AS best_elapsed_ms, + count(*) AS run_count +FROM run_samples +GROUP BY bucket, gamemode_id +WITH NO DATA; + +CREATE MATERIALIZED VIEW IF NOT EXISTS lootbox_drop_rates_hourly +WITH (timescaledb.continuous) AS +SELECT + time_bucket('1 hour', created_at) AS bucket, + (payload->>'lootboxId') AS lootbox_id, + cosmetic_id, + count(*) AS drop_count +FROM economy_ledger +WHERE event_type = 'item_granted' AND payload ? 'lootboxId' +GROUP BY bucket, lootbox_id, cosmetic_id +WITH NO DATA; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fabb152 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + timescaledb: + image: timescale/timescaledb:latest-pg16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: lunarpup + POSTGRES_USER: lunarpup + POSTGRES_PASSWORD: lunarpup + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lunarpup -d lunarpup"] + interval: 5s + timeout: 5s + retries: 12 + volumes: + - timescaledb-data:/var/lib/postgresql/data + +volumes: + timescaledb-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6885728 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,107 @@ +# Lunar Pup foundation contracts + +This repo keeps feature seams in plain TypeScript modules so parallel work can add files without editing the game loop or the server entry point. + +## Agent event protocol + +`src/contracts/agentEvents.ts` defines harness-to-server-to-client status messages: + +- `agent_session_start` +- `agent_status` +- `agent_needs_input` +- `agent_done` + +Every event carries `harness`, `sessionId`, `project`, `message`, and ISO-compatible `timestamp`. `validateAgentEvent` is the runtime gate for untrusted JSON. + +## Package manifests + +`src/contracts/packageManifest.ts` defines shareable mod-like content manifests. A manifest has: + +- `kind`: `cosmetic` or `gamemode` +- `version` +- `author` +- `displayName` +- `assetRefs`: named URI/media-type references with their own SHA-256 +- `id`: SHA-256 of the canonical JSON for the manifest without `id` + +`canonicalManifestJson` sorts object keys recursively. `packageManifestId` hashes that canonical JSON. `validatePackageManifest` rejects manifests whose `id` does not match the content. + +## Cosmetics + +`src/contracts/cosmetic.ts` defines cosmetic packages only; there is no shop or inventory UI here. + +- `slot`: `board`, `body`, `trail`, or `aura` +- `rarity`: `common`, `rare`, `epic`, or `legendary` +- `visual.colors`: `#RRGGBB` or `#RRGGBBAA` +- optional mesh parameters: `shape`, `scale`, `roughness`, `metalness` +- optional particle parameters: `count`, `size`, `lifetime`, `emissionRate` + +`validateCosmeticDefinition` is the runtime validator. + +## Gamemodes + +`src/contracts/gamemode.ts` defines the gamemode interface, not concrete game content. A gamemode provides: + +- `id` +- lifecycle: `init`, `start`, `tick`, `end` +- player hooks: `onPlayerJoin`, `onPlayerLeave` +- scoring: `score` +- win condition: `isWinConditionMet` +- checkpoint definitions with position, radius, and optional order + +`src/game/loop.ts` exposes `setCurrentGamemode(gamemode, state)`. When set, the loop increments `state.elapsedMs` and calls `gamemode.tick(dt, state)` each frame. + +## Room/lobby protocol + +`src/contracts/roomProtocol.ts` defines lobby messages: + +- client: `create_room`, `join_room`, `leave_room`, `list_rooms` +- server: `room_state`, `room_list` + +Room state includes `roomId`, `gamemodeId`, and player IDs. This is only the protocol contract; no lobby feature is built in this unit. + +## Currency, inventory, and ledger storage + +`src/contracts/services.ts` defines storage interfaces that can be implemented by SQLite now and Postgres/Timescale later. + +`CurrencyInventoryService`: + +- `getBalance(accountId)` +- `grant(accountId, amount, reason)` +- `spend(accountId, amount, reason)`; throws `InsufficientFundsError` when balance is too low +- `listOwnedItems(accountId)` +- `grantOwnedItem(accountId, cosmeticId, reason)` + +`EventLedgerStorage`: + +- `append({ type, entityId, timestamp, payload })` +- `query({ type, entityId, from, to })` + +The default backend is Bun SQLite at `data/lunarpup.db`. The `data` directory is gitignored. The ledger is append-only at the interface level: callers append typed events and query by time range/type/entity. + +## Server routing + +`src/server.ts` now owns only process wiring: + +- create a `ModularRouter` +- register server modules +- start `Bun.serve` +- pass WebSocket messages to the router + +`src/server/router.ts` supports: + +- HTTP routes registered by method/path +- WebSocket handlers registered by channel +- a default `multiplayer` channel when a legacy message has no `channel` field + +`src/server/multiplayer.ts` registers the existing join/state/leave flow on the `multiplayer` channel. Existing `src/net/client.ts` messages are unchanged, so current multiplayer clients continue sending `{ type: 'join' }` and `{ type: 'state' }` without a channel field. + +## Game loop extension hooks + +`src/game/loop.ts` exposes: + +- `registerUpdateHook(fn)` returns an unregister function and calls `fn(dt, state)` once per frame +- `setCurrentGamemode(gamemode, state)` attaches or clears a gamemode tick target +- `getCurrentGamemode()` reports the currently attached gamemode + +Hooks receive `playerGroup`, `physics`, `scene`, and `skateboard`. This lets cosmetics and gamemodes attach behavior without editing `loop.ts` again. diff --git a/docs/persistence.md b/docs/persistence.md new file mode 100644 index 0000000..f2a46df --- /dev/null +++ b/docs/persistence.md @@ -0,0 +1,56 @@ +# Persistence backends + +Lunar Pup has two persistence backends behind the same contracts in `src/contracts/services.ts`. + +## SQLite default + +SQLite is the offline/default backend. If `DATABASE_URL` is unset, `createStorageServices()` returns: + +- `SqliteEventLedgerStorage` +- `SqliteCurrencyInventoryService` + +The database file defaults to `data/lunarpup.db`. Tests use temporary SQLite files, so `bun test` stays offline and does not require Docker or Postgres. + +## TimescaleDB/Postgres backend + +Set `DATABASE_URL` to select the Postgres implementation: + +```sh +export DATABASE_URL=postgres://lunarpup:lunarpup@localhost:5432/lunarpup +bun run db:migrate +``` + +`createStorageServices()` then returns: + +- `PostgresEventLedgerStorage`, storing append-only ledger events in the `agent_events` hypertable. +- `PostgresCurrencyInventoryService`, storing account balances, owned cosmetics, and economy facts in `balances`, `owned_items`, and the `economy_ledger` hypertable. + +The migration in `db/migrations/001_timescale_persistence.sql` creates: + +- `agent_events`: general event ledger hypertable for the `EventLedgerStorage` contract. +- `run_samples`: gamemode completion samples for leaderboard analytics. +- `economy_ledger`: currency and item grant/spend facts. +- `best_time_leaderboards_hourly`: continuous aggregate with hourly best times per gamemode. +- `lootbox_drop_rates_hourly`: continuous aggregate inputs for hourly lootbox drop-rate stats. + +## Local TimescaleDB + +Start the database: + +```sh +docker compose up -d timescaledb +``` + +Run migrations: + +```sh +DATABASE_URL=postgres://lunarpup:lunarpup@localhost:5432/lunarpup bun run db:migrate +``` + +Run Postgres integration tests: + +```sh +TEST_DATABASE_URL=postgres://lunarpup:lunarpup@localhost:5432/lunarpup bun test src/contracts/services.test.ts +``` + +If `TEST_DATABASE_URL` is unset, the Postgres tests are skipped cleanly and the SQLite suite still runs. diff --git a/docs/solana.md b/docs/solana.md new file mode 100644 index 0000000..817dee2 --- /dev/null +++ b/docs/solana.md @@ -0,0 +1,79 @@ +# Solana devnet layer + +Lunar Pup keeps chain code under `src/solana/`. Game code imports the small TypeScript interfaces from `src/solana/interfaces.ts`; it does not import `@solana/web3.js`, SPL Token, or Metaplex directly. + +## Devnet setup + +1. Create or import a devnet authority keypair outside the repo. Do not commit keypairs. +2. Point services at devnet only: + +```bash +SOLANA_RPC_URL=https://api.devnet.solana.com +``` + +3. Construct services server-side with injected connections/signers: + +```ts +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { SolanaDevnetTokenService } from '../src/solana/token.ts'; + +const connection = new Connection('https://api.devnet.solana.com', 'confirmed'); +const service = new SolanaDevnetTokenService({ + connection, + payer: authorityKeypair, + mint: new PublicKey(process.env.DEVNET_TOKEN_MINT!), +}); +``` + +Tests must inject mocked RPC/UMI clients. They must not hit devnet. + +## Faucet + +Use Solana devnet faucet funding for the authority and test wallets: + +```bash +solana config set --url devnet +solana airdrop 2 +solana airdrop 1 +``` + +If the public faucet is rate-limited, wait and retry or use the official web faucet. Do not switch production code to mainnet-beta to work around faucet limits. + +## Wallet sign-in + +`src/server/wallet.ts` registers two HTTP endpoints through `createServerRouter()`: + +- `POST /wallet/challenge` with `{ "playerId": "...", "walletAddress": "..." }` +- `POST /wallet/verify` with `{ "playerId": "...", "walletAddress": "...", "nonce": "...", "signature": "" }` + +The server challenge message binds player id, wallet address, nonce, issue time, and the phrase `Devnet only.` The server verifies the Ed25519 signature against the Solana public key and records the wallet session for that player. + +## SPL token service + +`SolanaDevnetTokenService` implements the `CurrencyService` contract plus devnet SPL helpers: + +- `getBalance(walletAddress)` reads the wallet ATA balance. +- `mintToWallet(walletAddress, amount)` mints devnet tokens into the wallet ATA. +- `transferBetweenWallets(from, to, amount)` sends tokens when the injected payer owns the source wallet. +- Optional `inventory` delegates ledger/inventory methods to the existing SQLite-backed service. + +The service accepts an injected `Connection`, so tests mock RPC instead of contacting devnet. + +## Cosmetic NFTs + +`MetaplexDevnetNftService` mints Metaplex NFTs on devnet. The metadata URI encodes the cosmetic package id: + +```text +lunarpup://cosmetic/?cosmeticPackageId= +``` + +`grantOwnedCosmetics(accountId, walletAddress)` checks wallet token ownership and grants matching inventory items through the `CurrencyService` inventory methods. Tests use an in-memory UMI sender and mocked parsed token accounts. + +## Mainnet launch gate + +`scripts/solana/launch-mainnet.ts` is human-only. It refuses to run unless both gates pass: + +1. `MAINNET_LAUNCH_CONFIRM=YES_I_AM_SURE` +2. An interactive terminal prompt receives exactly `y` + +Non-interactive shells, CI, bots, and missing env confirmation fail closed. The script contains no autonomous launch path. diff --git a/package.json b/package.json index 1f8a2a9..3b6ec42 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,21 @@ "build": "bun scripts/build.ts", "test": "bun test", "typecheck": "bunx tsc --noEmit", + "db:migrate": "bun scripts/migrate.ts", "preview": "bunx serve dist" }, "dependencies": { + "@metaplex-foundation/mpl-token-metadata": "^3.4.0", + "@metaplex-foundation/umi": "^1.5.1", + "@metaplex-foundation/umi-bundle-defaults": "^1.5.1", + "@metaplex-foundation/umi-signer-wallet-adapters": "^1.5.1", "@netlify/blobs": "^10.7.0", "@netlify/functions": "^5.1.2", - "three": "^0.185.1" + "@solana/spl-token": "^0.4.15", + "@solana/web3.js": "^1.98.4", + "bs58": "^6.0.0", + "three": "^0.185.1", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@netlify/edge-functions": "^3.0.3", diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..c9b0360 --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,42 @@ +import { SQL } from 'bun'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +const migrationsDir = join(import.meta.dir, '..', 'db', 'migrations'); + +export async function runMigrations(databaseUrl = process.env.DATABASE_URL): Promise { + if (!databaseUrl) throw new Error('DATABASE_URL is required to run migrations'); + + const sql = new SQL(databaseUrl); + try { + await sql` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + const filenames = (await readdir(migrationsDir)) + .filter(filename => filename.endsWith('.sql')) + .sort(); + + for (const filename of filenames) { + const appliedRows = await sql<{ filename: string }[]>` + SELECT filename FROM schema_migrations WHERE filename = ${filename} + `; + if (appliedRows.length > 0) continue; + + const migrationSql = await Bun.file(join(migrationsDir, filename)).text(); + await sql.begin(async tx => { + await tx.unsafe(migrationSql); + await tx`INSERT INTO schema_migrations (filename) VALUES (${filename})`; + }); + } + } finally { + await sql.close(); + } +} + +if (import.meta.main) { + await runMigrations(); +} diff --git a/scripts/solana/launch-mainnet.ts b/scripts/solana/launch-mainnet.ts new file mode 100644 index 0000000..220610b --- /dev/null +++ b/scripts/solana/launch-mainnet.ts @@ -0,0 +1,31 @@ +import { createInterface } from 'node:readline/promises'; + +/** + * Human-only mainnet launch gate. Do not call from CI, tests, bots, or autonomous agents. + * This script intentionally requires both an environment variable and a live terminal y/N prompt. + */ +const CONFIRM_ENV_VALUE = 'YES_I_AM_SURE'; + +if (process.env.MAINNET_LAUNCH_CONFIRM !== CONFIRM_ENV_VALUE) { + console.error(`Refusing mainnet launch: set MAINNET_LAUNCH_CONFIRM=${CONFIRM_ENV_VALUE} only during a human-supervised release.`); + process.exit(1); +} + +const prompt = 'This will launch Lunar Pup Solana assets on MAINNET. Type y to continue [y/N]: '; +const answer = (await promptForHuman(prompt)).trim().toLowerCase(); +if (answer !== 'y') { + console.error('Mainnet launch aborted.'); + process.exit(1); +} + +console.log('Mainnet launch gate passed. Wire the audited release transaction flow here only after human review.'); + +async function promptForHuman(message: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Refusing mainnet launch without an interactive terminal.'); + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + return await readline.question(message); + } finally { + readline.close(); + } +} diff --git a/src/contracts/agentEvents.ts b/src/contracts/agentEvents.ts new file mode 100644 index 0000000..66bb37d --- /dev/null +++ b/src/contracts/agentEvents.ts @@ -0,0 +1,31 @@ +import { fail, isRecord, ok, readEnum, readString, type ValidationResult } from './validators.ts'; + +export const agentEventTypes = ['agent_session_start', 'agent_status', 'agent_needs_input', 'agent_done'] as const; +export type AgentEventType = (typeof agentEventTypes)[number]; + +export interface AgentEvent { + type: AgentEventType; + harness: string; + sessionId: string; + project: string; + message: string; + timestamp: string; +} + +export function validateAgentEvent(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('agent event must be an object'); + const type = readEnum(value, 'type', agentEventTypes); + if (!type.ok) return type; + const harness = readString(value, 'harness'); + if (!harness.ok) return harness; + const sessionId = readString(value, 'sessionId'); + if (!sessionId.ok) return sessionId; + const project = readString(value, 'project'); + if (!project.ok) return project; + const message = readString(value, 'message'); + if (!message.ok) return message; + const timestamp = readString(value, 'timestamp'); + if (!timestamp.ok) return timestamp; + if (Number.isNaN(Date.parse(timestamp.value))) return fail('timestamp must be an ISO-compatible date string'); + return ok({ type: type.value, harness: harness.value, sessionId: sessionId.value, project: project.value, message: message.value, timestamp: timestamp.value }); +} diff --git a/src/contracts/contracts.test.ts b/src/contracts/contracts.test.ts new file mode 100644 index 0000000..b73d29f --- /dev/null +++ b/src/contracts/contracts.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test'; +import { + packageManifestId, + validateAgentEvent, + validateCosmeticDefinition, + validatePackageManifest, + validateRoomClientMessage, + validateRoomServerMessage, + type AgentEvent, + type CosmeticDefinition, + type PackageManifest, + type ValidationResult, +} from './index.ts'; + +function expectOk(result: ValidationResult): T { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + return result.value; +} + +function expectError(result: ValidationResult, error: string): void { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected validation to fail'); + expect(result.error).toBe(error); +} + +function validManifest(): PackageManifest { + const withoutId: Omit = { + kind: 'cosmetic', + version: '1.0.0', + author: 'Moon Kennel', + displayName: 'Comet Trail', + assetRefs: [{ + name: 'trail', + uri: 'ipfs://comet-trail', + sha256: 'a'.repeat(64), + mediaType: 'model/gltf+json', + }], + metadata: { license: 'CC0' }, + }; + return { id: packageManifestId(withoutId), ...withoutId }; +} + +describe('runtime contract validators', () => { + test('accepts valid agent events and rejects invalid event types and timestamps', () => { + const event: AgentEvent = { + type: 'agent_status', + harness: 'omp-squad', + sessionId: 'session-1', + project: 'lunarpup', + message: 'skating', + timestamp: '2026-07-09T12:00:00.000Z', + }; + + expect(expectOk(validateAgentEvent(event))).toEqual(event); + expectError(validateAgentEvent({ ...event, type: 'agent_started' }), 'type must be one of agent_session_start, agent_status, agent_needs_input, agent_done'); + expectError(validateAgentEvent({ ...event, timestamp: 'not-a-date' }), 'timestamp must be an ISO-compatible date string'); + }); + + test('accepts complete cosmetic definitions and rejects malformed visuals', () => { + const cosmetic: CosmeticDefinition = { + id: 'comet-board', + slot: 'board', + rarity: 'epic', + visual: { + colors: ['#00ffcc', '#ffffff80'], + mesh: { shape: 'box', scale: [1, 0.2, 2], roughness: 0.75, metalness: 0.1 }, + particles: { count: 32, size: 0.08, lifetime: 0.5, emissionRate: 12 }, + }, + }; + + expect(expectOk(validateCosmeticDefinition(cosmetic))).toEqual(cosmetic); + expectError(validateCosmeticDefinition({ ...cosmetic, slot: 'helmet' }), 'slot must be one of board, body, trail, aura'); + expectError( + validateCosmeticDefinition({ ...cosmetic, visual: { ...cosmetic.visual, colors: ['#00ffcc', 'blue'] } }), + 'visual.colors[1] must be a #RRGGBB or #RRGGBBAA color', + ); + expectError( + validateCosmeticDefinition({ ...cosmetic, visual: { ...cosmetic.visual, mesh: { shape: 'sphere', scale: [1, Number.POSITIVE_INFINITY, 1] } } }), + 'visual.mesh.scale values must be finite', + ); + }); + + test('accepts package manifests only when the canonical id and asset hashes are valid', () => { + const manifest = validManifest(); + + expect(expectOk(validatePackageManifest(manifest))).toEqual(manifest); + expectError( + validatePackageManifest({ ...manifest, id: '0'.repeat(64) }), + `id must be sha256 of canonical manifest JSON (${manifest.id})`, + ); + expectError( + validatePackageManifest({ ...manifest, assetRefs: [{ ...manifest.assetRefs[0], sha256: 'A'.repeat(64) }] }), + 'assetRefs[0].sha256 must be 64 lowercase hex characters', + ); + }); + + test('validates room client commands and room server broadcasts', () => { + expect(expectOk(validateRoomClientMessage({ type: 'create_room', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerId: 'pup-1' }))).toEqual({ + type: 'create_room', + roomId: 'moon-bowl', + gamemodeId: 'checkpoint-race', + playerId: 'pup-1', + }); + expect(expectOk(validateRoomClientMessage({ type: 'list_rooms' }))).toEqual({ type: 'list_rooms' }); + expectError(validateRoomClientMessage({ type: 'create_room', roomId: 'moon-bowl', playerId: 'pup-1' }), 'gamemodeId must be a non-empty string'); + + expect(expectOk(validateRoomServerMessage({ type: 'room_state', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', players: ['pup-1', 'pup-2'] }))).toEqual({ + type: 'room_state', + roomId: 'moon-bowl', + gamemodeId: 'checkpoint-race', + players: ['pup-1', 'pup-2'], + }); + expect(expectOk(validateRoomServerMessage({ type: 'room_list', rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 2 }] }))).toEqual({ + type: 'room_list', + rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 2 }], + }); + expectError(validateRoomServerMessage({ type: 'room_list', rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 'two' }] }), 'rooms[0].playerCount must be a finite number'); + expectError(validateRoomServerMessage({ type: 'room_state', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', players: ['pup-1', 2] }), 'players[1] must be a string'); + }); +}); diff --git a/src/contracts/cosmetic.ts b/src/contracts/cosmetic.ts new file mode 100644 index 0000000..a6db62f --- /dev/null +++ b/src/contracts/cosmetic.ts @@ -0,0 +1,98 @@ +import { fail, isRecord, ok, readArray, readEnum, readNumber, type ValidationResult, type Validator } from './validators.ts'; + +export const cosmeticSlots = ['board', 'body', 'trail', 'aura'] as const; +export type CosmeticSlot = (typeof cosmeticSlots)[number]; + +export const cosmeticRarities = ['common', 'rare', 'epic', 'legendary'] as const; +export type CosmeticRarity = (typeof cosmeticRarities)[number]; + +export interface MeshParams { + shape: 'box' | 'sphere' | 'cylinder' | 'custom'; + scale: [number, number, number]; + roughness?: number; + metalness?: number; +} + +export interface ParticleParams { + count: number; + size: number; + lifetime: number; + emissionRate: number; +} + +export interface CosmeticVisualSpec { + colors: string[]; + mesh?: MeshParams; + particles?: ParticleParams; +} + +export interface CosmeticDefinition { + id: string; + slot: CosmeticSlot; + visual: CosmeticVisualSpec; + rarity: CosmeticRarity; +} + +const meshShapes = ['box', 'sphere', 'cylinder', 'custom'] as const; +const colorPattern = /^#(?:[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/; + +const validateColor: Validator = (value, path = 'color') => { + return typeof value === 'string' && colorPattern.test(value) ? ok(value) : fail(`${path} must be a #RRGGBB or #RRGGBBAA color`); +}; + +function validateTuple3(value: unknown, path: string): ValidationResult<[number, number, number]> { + if (!Array.isArray(value) || value.length !== 3) return fail(`${path} must be a three-number tuple`); + const [x, y, z] = value; + if (typeof x !== 'number' || typeof y !== 'number' || typeof z !== 'number') return fail(`${path} must be a three-number tuple`); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return fail(`${path} values must be finite`); + return ok([x, y, z]); +} + +function validateMeshParams(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual.mesh must be an object'); + const shape = readEnum(value, 'shape', meshShapes, 'visual.mesh.shape'); + if (!shape.ok) return shape; + const scale = validateTuple3(value.scale, 'visual.mesh.scale'); + if (!scale.ok) return scale; + const roughness = value.roughness === undefined ? undefined : readNumber(value, 'roughness', 'visual.mesh.roughness'); + if (roughness && !roughness.ok) return roughness; + const metalness = value.metalness === undefined ? undefined : readNumber(value, 'metalness', 'visual.mesh.metalness'); + if (metalness && !metalness.ok) return metalness; + return ok({ shape: shape.value, scale: scale.value, roughness: roughness?.value, metalness: metalness?.value }); +} + +function validateParticleParams(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual.particles must be an object'); + const count = readNumber(value, 'count', 'visual.particles.count'); + if (!count.ok) return count; + const size = readNumber(value, 'size', 'visual.particles.size'); + if (!size.ok) return size; + const lifetime = readNumber(value, 'lifetime', 'visual.particles.lifetime'); + if (!lifetime.ok) return lifetime; + const emissionRate = readNumber(value, 'emissionRate', 'visual.particles.emissionRate'); + if (!emissionRate.ok) return emissionRate; + return ok({ count: count.value, size: size.value, lifetime: lifetime.value, emissionRate: emissionRate.value }); +} + +function validateVisualSpec(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual must be an object'); + const colors = readArray(value, 'colors', validateColor, 'visual.colors'); + if (!colors.ok) return colors; + const mesh = value.mesh === undefined ? undefined : validateMeshParams(value.mesh); + if (mesh && !mesh.ok) return mesh; + const particles = value.particles === undefined ? undefined : validateParticleParams(value.particles); + if (particles && !particles.ok) return particles; + return ok({ colors: colors.value, mesh: mesh?.value, particles: particles?.value }); +} + +export function validateCosmeticDefinition(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('cosmetic definition must be an object'); + if (typeof value.id !== 'string' || value.id.length === 0) return fail('id must be a non-empty string'); + const slot = readEnum(value, 'slot', cosmeticSlots); + if (!slot.ok) return slot; + const visual = validateVisualSpec(value.visual); + if (!visual.ok) return visual; + const rarity = readEnum(value, 'rarity', cosmeticRarities); + if (!rarity.ok) return rarity; + return ok({ id: value.id, slot: slot.value, visual: visual.value, rarity: rarity.value }); +} diff --git a/src/contracts/gamemode.ts b/src/contracts/gamemode.ts new file mode 100644 index 0000000..bbc539b --- /dev/null +++ b/src/contracts/gamemode.ts @@ -0,0 +1,82 @@ +import type { PlayerSnapshot } from '../net/protocol.ts'; +import { fail, isRecord, ok, readArray, readNumber, readString, type ValidationResult, type Validator } from './validators.ts'; + +export interface CheckpointDefinition { + id: string; + position: { x: number; y: number; z: number }; + radius: number; + order?: number; +} + +export interface GamemodeRuntimeState { + players: Map; + scores: Map; + checkpoints: CheckpointDefinition[]; + elapsedMs: number; +} + +export interface GamemodeContext { + roomId: string; + now: () => number; + broadcast: (message: unknown) => void; +} + +export interface Gamemode { + id: string; + checkpoints: CheckpointDefinition[]; + init(context: GamemodeContext): void | Promise; + start(state: GamemodeRuntimeState): void | Promise; + tick(dt: number, state: GamemodeRuntimeState): void | Promise; + end(state: GamemodeRuntimeState): void | Promise; + onPlayerJoin(player: PlayerSnapshot, state: GamemodeRuntimeState): void | Promise; + onPlayerLeave(playerId: string, state: GamemodeRuntimeState): void | Promise; + score(playerId: string, state: GamemodeRuntimeState): number; + isWinConditionMet(state: GamemodeRuntimeState): boolean; +} + +function validatePosition(value: unknown, path: string): ValidationResult<{ x: number; y: number; z: number }> { + if (!isRecord(value)) return fail(`${path} must be an object`); + const x = readNumber(value, 'x', `${path}.x`); + if (!x.ok) return x; + const y = readNumber(value, 'y', `${path}.y`); + if (!y.ok) return y; + const z = readNumber(value, 'z', `${path}.z`); + if (!z.ok) return z; + return ok({ x: x.value, y: y.value, z: z.value }); +} + +const validateCheckpoint: Validator = (value, path = 'checkpoint') => { + if (!isRecord(value)) return fail(`${path} must be an object`); + const id = readString(value, 'id', `${path}.id`); + if (!id.ok) return id; + const position = validatePosition(value.position, `${path}.position`); + if (!position.ok) return position; + const radius = readNumber(value, 'radius', `${path}.radius`); + if (!radius.ok) return radius; + const order = value.order === undefined ? undefined : readNumber(value, 'order', `${path}.order`); + if (order && !order.ok) return order; + return ok({ id: id.value, position: position.value, radius: radius.value, order: order?.value }); +}; + +export function validateCheckpointDefinition(value: unknown): ValidationResult { + return validateCheckpoint(value, 'checkpoint'); +} + +export function validateCheckpointDefinitions(value: unknown): ValidationResult { + if (!isRecord({ checkpoints: value })) return fail('checkpoints must be an array'); + return readArray({ checkpoints: value }, 'checkpoints', validateCheckpoint); +} + +export function isGamemode(value: unknown): value is Gamemode { + if (!isRecord(value)) return false; + return typeof value.id === 'string' + && Array.isArray(value.checkpoints) + && typeof value.init === 'function' + && typeof value.start === 'function' + && typeof value.tick === 'function' + && typeof value.end === 'function' + && typeof value.onPlayerJoin === 'function' + && typeof value.onPlayerLeave === 'function' + && typeof value.score === 'function' + && typeof value.isWinConditionMet === 'function'; +} diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 0000000..6a6d16c --- /dev/null +++ b/src/contracts/index.ts @@ -0,0 +1,7 @@ +export * from './agentEvents.ts'; +export * from './cosmetic.ts'; +export * from './gamemode.ts'; +export * from './packageManifest.ts'; +export * from './roomProtocol.ts'; +export * from './services.ts'; +export * from './validators.ts'; diff --git a/src/contracts/packageManifest.ts b/src/contracts/packageManifest.ts new file mode 100644 index 0000000..258a314 --- /dev/null +++ b/src/contracts/packageManifest.ts @@ -0,0 +1,82 @@ +import { sha256Hex } from './sha256.ts'; +import { fail, isRecord, ok, readArray, readEnum, readRecordOfStrings, readString, type ValidationResult, type Validator } from './validators.ts'; + +export const packageKinds = ['cosmetic', 'gamemode'] as const; +export type PackageKind = (typeof packageKinds)[number]; + +export interface AssetRef { + name: string; + uri: string; + sha256: string; + mediaType: string; +} + +export interface PackageManifest { + id: string; + kind: PackageKind; + version: string; + author: string; + displayName: string; + assetRefs: AssetRef[]; + metadata?: Record; +} + +const sha256Pattern = /^[a-f0-9]{64}$/; + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalize(entryValue)}`).join(',')}}`; +} + +export function canonicalManifestJson(manifest: Omit): string { + return canonicalize(manifest); +} + +export function packageManifestId(manifest: Omit): string { + return sha256Hex(canonicalManifestJson(manifest)); +} + +const validateAssetRef: Validator = (value, path = 'assetRefs[]') => { + if (!isRecord(value)) return fail(`${path} must be an object`); + const name = readString(value, 'name', `${path}.name`); + if (!name.ok) return name; + const uri = readString(value, 'uri', `${path}.uri`); + if (!uri.ok) return uri; + const sha256 = readString(value, 'sha256', `${path}.sha256`); + if (!sha256.ok) return sha256; + if (!sha256Pattern.test(sha256.value)) return fail(`${path}.sha256 must be 64 lowercase hex characters`); + const mediaType = readString(value, 'mediaType', `${path}.mediaType`); + if (!mediaType.ok) return mediaType; + return ok({ name: name.value, uri: uri.value, sha256: sha256.value, mediaType: mediaType.value }); +}; + +export function validatePackageManifest(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('package manifest must be an object'); + const id = readString(value, 'id'); + if (!id.ok) return id; + if (!sha256Pattern.test(id.value)) return fail('id must be 64 lowercase hex characters'); + const kind = readEnum(value, 'kind', packageKinds); + if (!kind.ok) return kind; + const version = readString(value, 'version'); + if (!version.ok) return version; + const author = readString(value, 'author'); + if (!author.ok) return author; + const displayName = readString(value, 'displayName'); + if (!displayName.ok) return displayName; + const assetRefs = readArray(value, 'assetRefs', validateAssetRef); + if (!assetRefs.ok) return assetRefs; + let metadata: Record | undefined; + if (value.metadata !== undefined) { + const parsed = readRecordOfStrings(value, 'metadata'); + if (!parsed.ok) return parsed; + metadata = parsed.value; + } + const withoutId: Omit = { kind: kind.value, version: version.value, author: author.value, displayName: displayName.value, assetRefs: assetRefs.value, metadata }; + const expectedId = packageManifestId(withoutId); + if (id.value !== expectedId) return fail(`id must be sha256 of canonical manifest JSON (${expectedId})`); + return ok({ id: id.value, ...withoutId }); +} diff --git a/src/contracts/roomProtocol.ts b/src/contracts/roomProtocol.ts new file mode 100644 index 0000000..024465c --- /dev/null +++ b/src/contracts/roomProtocol.ts @@ -0,0 +1,98 @@ +import { fail, isRecord, ok, readArray, readEnum, readNumber, readString, type ValidationResult, type Validator } from './validators.ts'; + +export const roomClientMessageTypes = ['create_room', 'join_room', 'leave_room', 'list_rooms', 'start_gamemode'] as const; +export type RoomClientMessageType = (typeof roomClientMessageTypes)[number]; + +export interface CreateRoomMessage { type: 'create_room'; roomId: string; gamemodeId: string; playerId: string } +export interface JoinRoomMessage { type: 'join_room'; roomId: string; playerId: string } +export interface LeaveRoomMessage { type: 'leave_room'; roomId: string; playerId: string } +export interface ListRoomsMessage { type: 'list_rooms' } +export interface StartGamemodeMessage { type: 'start_gamemode'; roomId: string; playerId: string } +export type RoomClientMessage = CreateRoomMessage | JoinRoomMessage | LeaveRoomMessage | ListRoomsMessage | StartGamemodeMessage; + +export interface RoomSummary { + roomId: string; + gamemodeId: string; + playerCount: number; +} + +export interface RoomStateBroadcast { + type: 'room_state'; + roomId: string; + gamemodeId: string; + players: string[]; +} + +export interface RoomListMessage { + type: 'room_list'; + rooms: RoomSummary[]; +} + +export interface StartGamemodeBroadcast { + type: 'start_gamemode'; + roomId: string; + gamemodeId: string; + hostId: string; +} + +export type RoomServerMessage = RoomStateBroadcast | RoomListMessage | StartGamemodeBroadcast; + +function validateRoomSummary(value: unknown, path = 'rooms[]'): ValidationResult { + if (!isRecord(value)) return fail(`${path} must be an object`); + const roomId = readString(value, 'roomId', `${path}.roomId`); + if (!roomId.ok) return roomId; + const gamemodeId = readString(value, 'gamemodeId', `${path}.gamemodeId`); + if (!gamemodeId.ok) return gamemodeId; + const playerCount = readNumber(value, 'playerCount', `${path}.playerCount`); + if (!playerCount.ok) return playerCount; + return ok({ roomId: roomId.value, gamemodeId: gamemodeId.value, playerCount: playerCount.value }); +} + +const validateStringItem: Validator = (value, path = 'item') => { + return typeof value === 'string' ? ok(value) : fail(`${path} must be a string`); +}; + +export function validateRoomClientMessage(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('room message must be an object'); + const type = readEnum(value, 'type', roomClientMessageTypes); + if (!type.ok) return type; + if (type.value === 'list_rooms') return ok({ type: 'list_rooms' }); + const roomId = readString(value, 'roomId'); + if (!roomId.ok) return roomId; + const playerId = readString(value, 'playerId'); + if (!playerId.ok) return playerId; + if (type.value === 'create_room') { + const gamemodeId = readString(value, 'gamemodeId'); + if (!gamemodeId.ok) return gamemodeId; + return ok({ type: 'create_room', roomId: roomId.value, gamemodeId: gamemodeId.value, playerId: playerId.value }); + } + return ok({ type: type.value, roomId: roomId.value, playerId: playerId.value }); +} + +export function validateRoomServerMessage(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('room server message must be an object'); + if (value.type === 'room_list') { + const rooms = readArray(value, 'rooms', validateRoomSummary); + if (!rooms.ok) return rooms; + return ok({ type: 'room_list', rooms: rooms.value }); + } + if (value.type === 'start_gamemode') { + const roomId = readString(value, 'roomId'); + if (!roomId.ok) return roomId; + const gamemodeId = readString(value, 'gamemodeId'); + if (!gamemodeId.ok) return gamemodeId; + const hostId = readString(value, 'hostId'); + if (!hostId.ok) return hostId; + return ok({ type: 'start_gamemode', roomId: roomId.value, gamemodeId: gamemodeId.value, hostId: hostId.value }); + } + if (value.type === 'room_state') { + const roomId = readString(value, 'roomId'); + if (!roomId.ok) return roomId; + const gamemodeId = readString(value, 'gamemodeId'); + if (!gamemodeId.ok) return gamemodeId; + const players = readArray(value, 'players', validateStringItem); + if (!players.ok) return players; + return ok({ type: 'room_state', roomId: roomId.value, gamemodeId: gamemodeId.value, players: players.value }); + } + return fail('type must be room_state, room_list, or start_gamemode'); +} diff --git a/src/contracts/services.test.ts b/src/contracts/services.test.ts new file mode 100644 index 0000000..748eee8 --- /dev/null +++ b/src/contracts/services.test.ts @@ -0,0 +1,134 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { SQL } from 'bun'; +import { + createStorageServices, + InsufficientFundsError, + PostgresCurrencyInventoryService, + PostgresEventLedgerStorage, + SqliteCurrencyInventoryService, + SqliteEventLedgerStorage, + type CurrencyInventoryService, + type EventLedgerStorage, +} from './services.ts'; +import { runMigrations } from '../../scripts/migrate.ts'; + +const dbPaths: string[] = []; +const testDatabaseUrl = process.env.TEST_DATABASE_URL; +let postgresSql: SQL | undefined; + +function tempDbPath(name: string): string { + const path = join(tmpdir(), `lunarpup-${name}-${crypto.randomUUID()}.db`); + dbPaths.push(path, `${path}-shm`, `${path}-wal`); + return path; +} + +async function resetPostgres(sql: SQL): Promise { + await sql`TRUNCATE agent_events, economy_ledger, run_samples, balances, owned_items RESTART IDENTITY CASCADE`; +} + +afterEach(() => { + for (const path of dbPaths.splice(0)) { + if (existsSync(path)) rmSync(path, { force: true }); + } +}); + +beforeAll(async () => { + if (!testDatabaseUrl) return; + await runMigrations(testDatabaseUrl); + postgresSql = new SQL(testDatabaseUrl); +}); + +afterAll(async () => { + await postgresSql?.close(); +}); + +describe('storage factory', () => { + test('defaults to sqlite when DATABASE_URL is unset', () => { + const previous = process.env.DATABASE_URL; + delete process.env.DATABASE_URL; + try { + const services = createStorageServices({ sqlite: { path: tempDbPath('factory') } }); + expect(services.backend).toBe('sqlite'); + expect(services.currencyInventory).toBeInstanceOf(SqliteCurrencyInventoryService); + expect(services.eventLedger).toBeInstanceOf(SqliteEventLedgerStorage); + } finally { + if (previous === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previous; + } + }); +}); + +type StorageFactory = () => Promise<{ ledger: EventLedgerStorage; service: CurrencyInventoryService }>; + +function runServiceSuite(name: string, factory: StorageFactory): void { + describe(name, () => { + test('appends typed events and queries by type and time range', async () => { + const { ledger } = await factory(); + await ledger.append({ type: 'currency_granted', entityId: 'player-1', timestamp: '2026-07-09T10:00:00.000Z', payload: { amount: 10 } }); + await ledger.append({ type: 'item_granted', entityId: 'player-1', timestamp: '2026-07-09T11:00:00.000Z', payload: { cosmeticId: 'board-1' } }); + await ledger.append({ type: 'currency_granted', entityId: 'player-2', timestamp: '2026-07-09T12:00:00.000Z', payload: { amount: 5 } }); + + const currencyEvents = await ledger.query({ type: 'currency_granted', from: '2026-07-09T09:30:00.000Z', to: '2026-07-09T11:30:00.000Z' }); + expect(currencyEvents).toHaveLength(1); + expect(currencyEvents[0]?.entityId).toBe('player-1'); + expect(currencyEvents[0]?.payload).toEqual({ amount: 10 }); + }); + + test('grants, spends, rejects insufficient funds, and lists owned cosmetics', async () => { + const { ledger, service } = await factory(); + + expect(await service.getBalance('player-1')).toBe(0); + expect(await service.grant('player-1', 100, 'test grant')).toBe(100); + expect(await service.spend('player-1', 35, 'test spend')).toBe(65); + let insufficientFundsError: unknown; + try { + await service.spend('player-1', 100, 'too much'); + } catch (error) { + insufficientFundsError = error; + } + expect(insufficientFundsError).toBeInstanceOf(InsufficientFundsError); + + await service.grantOwnedItem('player-1', 'moon-board', 'test item'); + await service.grantOwnedItem('player-1', 'moon-board', 'duplicate item'); + await service.grantOwnedItem('player-1', 'comet-trail', 'test item'); + expect(await service.listOwnedItems('player-1')).toEqual(['comet-trail', 'moon-board']); + + const events = await ledger.query({ entityId: 'player-1' }); + expect(events.map(event => event.type)).toEqual(['currency_granted', 'currency_spent', 'item_granted', 'item_granted']); + }); + }); +} + +runServiceSuite('sqlite storage services', async () => { + const path = tempDbPath('sqlite-suite'); + const ledger = new SqliteEventLedgerStorage({ path }); + const service = new SqliteCurrencyInventoryService({ path, ledger }); + return { ledger, service }; +}); + +if (testDatabaseUrl) { + runServiceSuite('postgres storage services', async () => { + if (!postgresSql) throw new Error('postgres test connection was not initialized'); + await resetPostgres(postgresSql); + const ledger = new PostgresEventLedgerStorage({ sql: postgresSql }); + const service = new PostgresCurrencyInventoryService({ sql: postgresSql, ledger }); + return { ledger, service }; + }); + + describe('postgres storage factory', () => { + test('uses postgres when DATABASE_URL is set', () => { + if (!testDatabaseUrl) throw new Error('TEST_DATABASE_URL is required'); + const services = createStorageServices({ postgres: { url: testDatabaseUrl } }); + expect(services.backend).toBe('postgres'); + expect(services.currencyInventory).toBeInstanceOf(PostgresCurrencyInventoryService); + expect(services.eventLedger).toBeInstanceOf(PostgresEventLedgerStorage); + }); + }); +} else { + describe.skip('postgres storage services', () => { + test('skips when TEST_DATABASE_URL is unset', () => {}); + }); +} diff --git a/src/contracts/services.ts b/src/contracts/services.ts new file mode 100644 index 0000000..2d821a3 --- /dev/null +++ b/src/contracts/services.ts @@ -0,0 +1,348 @@ +import { SQL } from 'bun'; +import { Database } from 'bun:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export class InsufficientFundsError extends Error { + constructor(public readonly accountId: string, public readonly balance: number, public readonly required: number) { + super(`insufficient funds for ${accountId}: balance ${balance}, required ${required}`); + this.name = 'InsufficientFundsError'; + } +} + +export interface CurrencyInventoryService { + getBalance(accountId: string): Promise | number; + grant(accountId: string, amount: number, reason: string): Promise | number; + spend(accountId: string, amount: number, reason: string): Promise | number; + listOwnedItems(accountId: string): Promise | string[]; + grantOwnedItem(accountId: string, cosmeticId: string, reason: string): Promise | void; +} +export type CurrencyService = CurrencyInventoryService; + +export interface LedgerEvent { + id?: number; + type: TType; + entityId?: string; + timestamp: string; + payload: TPayload; +} + +export interface LedgerQuery { + type?: string; + from?: string; + to?: string; + entityId?: string; +} + +export interface EventLedgerStorage { + append(event: Omit, 'id'>): Promise> | LedgerEvent; + query(query: LedgerQuery): Promise | LedgerEvent[]; +} + +export interface SqliteBackendOptions { + path?: string; +} + +export interface PostgresBackendOptions { + url?: string; + sql?: SQL; +} + +export interface StorageServices { + backend: 'sqlite' | 'postgres'; + currencyInventory: CurrencyInventoryService; + eventLedger: EventLedgerStorage; +} + +type SqliteLedgerRow = { + id: number; + type: string; + entity_id: string | null; + timestamp: string; + payload_json: string; +}; + +type BalanceRow = { + balance: number; +}; + +type OwnedItemRow = { + cosmetic_id: string; +}; + +type PostgresLedgerRow = { + id: number | string | bigint; + type: string; + entity_id: string | null; + timestamp: Date | string; + payload: unknown; +}; + +type PostgresBalanceRow = { + balance: number; +}; + +type PostgresOwnedItemRow = { + cosmetic_id: string; +}; + +const defaultDbPath = 'data/lunarpup.db'; + +function openDatabase(path: string): Database { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS ledger_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + entity_id TEXT, + timestamp TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ledger_type_time ON ledger_events(type, timestamp); + CREATE INDEX IF NOT EXISTS idx_ledger_entity_time ON ledger_events(entity_id, timestamp); + CREATE TABLE IF NOT EXISTS balances ( + account_id TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS owned_items ( + account_id TEXT NOT NULL, + cosmetic_id TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (account_id, cosmetic_id) + ); + `); + return db; +} + +function eventTimestamp(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function eventId(value: number | string | bigint): number { + return Number(value); +} + +export class SqliteEventLedgerStorage implements EventLedgerStorage { + readonly db: Database; + + constructor(options: SqliteBackendOptions = {}) { + this.db = openDatabase(options.path ?? defaultDbPath); + } + + append(event: Omit, 'id'>): LedgerEvent { + const timestamp = event.timestamp || new Date().toISOString(); + const result = this.db.query('INSERT INTO ledger_events (type, entity_id, timestamp, payload_json) VALUES ($type, $entityId, $timestamp, $payload)').run({ + $type: event.type, + $entityId: event.entityId ?? null, + $timestamp: timestamp, + $payload: JSON.stringify(event.payload), + }); + return { ...event, timestamp, id: Number(result.lastInsertRowid) }; + } + + query(query: LedgerQuery = {}): LedgerEvent[] { + const clauses: string[] = []; + const params: Record = {}; + if (query.type) { + clauses.push('type = $type'); + params.$type = query.type; + } + if (query.entityId) { + clauses.push('entity_id = $entityId'); + params.$entityId = query.entityId; + } + if (query.from) { + clauses.push('timestamp >= $from'); + params.$from = query.from; + } + if (query.to) { + clauses.push('timestamp <= $to'); + params.$to = query.to; + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const rows = this.db.query>(`SELECT id, type, entity_id, timestamp, payload_json FROM ledger_events ${where} ORDER BY timestamp ASC, id ASC`).all(params); + return rows.map(row => ({ id: row.id, type: row.type, entityId: row.entity_id ?? undefined, timestamp: row.timestamp, payload: JSON.parse(row.payload_json) })); + } +} + +export class SqliteCurrencyInventoryService implements CurrencyInventoryService { + readonly db: Database; + readonly ledger: EventLedgerStorage; + + constructor(options: SqliteBackendOptions & { ledger?: EventLedgerStorage; db?: Database } = {}) { + this.db = options.db ?? openDatabase(options.path ?? defaultDbPath); + this.ledger = options.ledger ?? new SqliteEventLedgerStorage({ path: options.path ?? defaultDbPath }); + } + + getBalance(accountId: string): number { + const row = this.db.query('SELECT balance FROM balances WHERE account_id = $accountId').get({ $accountId: accountId }); + return row?.balance ?? 0; + } + + grant(accountId: string, amount: number, reason: string): number { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('grant amount must be a positive integer'); + this.db.query(` + INSERT INTO balances (account_id, balance) VALUES ($accountId, $amount) + ON CONFLICT(account_id) DO UPDATE SET balance = balance + excluded.balance + `).run({ $accountId: accountId, $amount: amount }); + const balance = this.getBalance(accountId); + this.ledger.append({ type: 'currency_granted', entityId: accountId, timestamp: new Date().toISOString(), payload: { amount, reason, balance } }); + return balance; + } + + spend(accountId: string, amount: number, reason: string): number { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('spend amount must be a positive integer'); + const balance = this.getBalance(accountId); + if (balance < amount) throw new InsufficientFundsError(accountId, balance, amount); + this.db.query('UPDATE balances SET balance = balance - $amount WHERE account_id = $accountId').run({ $accountId: accountId, $amount: amount }); + const nextBalance = balance - amount; + this.ledger.append({ type: 'currency_spent', entityId: accountId, timestamp: new Date().toISOString(), payload: { amount, reason, balance: nextBalance } }); + return nextBalance; + } + + listOwnedItems(accountId: string): string[] { + const rows = this.db.query('SELECT cosmetic_id FROM owned_items WHERE account_id = $accountId ORDER BY cosmetic_id ASC').all({ $accountId: accountId }); + return rows.map(row => row.cosmetic_id); + } + + grantOwnedItem(accountId: string, cosmeticId: string, reason: string): void { + const result = this.db.query('INSERT OR IGNORE INTO owned_items (account_id, cosmetic_id, granted_at) VALUES ($accountId, $cosmeticId, $grantedAt)').run({ + $accountId: accountId, + $cosmeticId: cosmeticId, + $grantedAt: new Date().toISOString(), + }); + if (result.changes > 0) { + this.ledger.append({ type: 'item_granted', entityId: accountId, timestamp: new Date().toISOString(), payload: { cosmeticId, reason } }); + } + } +} + +export class PostgresEventLedgerStorage implements EventLedgerStorage { + readonly sql: SQL; + + constructor(options: PostgresBackendOptions = {}) { + const url = options.url ?? process.env.DATABASE_URL; + if (!options.sql && !url) throw new Error('DATABASE_URL is required for Postgres storage'); + this.sql = options.sql ?? new SQL(url ?? ''); + } + + async append(event: Omit, 'id'>): Promise> { + const timestamp = event.timestamp || new Date().toISOString(); + const rows = await this.sql` + INSERT INTO agent_events (type, entity_id, timestamp, payload) + VALUES (${event.type}, ${event.entityId ?? null}, ${timestamp}, ${JSON.stringify(event.payload)}::jsonb) + RETURNING id, type, entity_id, timestamp, payload + `; + const row = rows[0]; + if (!row) throw new Error('agent event insert returned no row'); + return { id: eventId(row.id), type: row.type as TType, entityId: row.entity_id ?? undefined, timestamp: eventTimestamp(row.timestamp), payload: row.payload as TPayload }; + } + + async query(query: LedgerQuery = {}): Promise { + const rows = await this.sql` + SELECT id, type, entity_id, timestamp, payload + FROM agent_events + WHERE (${query.type ?? null}::text IS NULL OR type = ${query.type ?? null}) + AND (${query.entityId ?? null}::text IS NULL OR entity_id = ${query.entityId ?? null}) + AND (${query.from ?? null}::timestamptz IS NULL OR timestamp >= ${query.from ?? null}::timestamptz) + AND (${query.to ?? null}::timestamptz IS NULL OR timestamp <= ${query.to ?? null}::timestamptz) + ORDER BY timestamp ASC, id ASC + `; + return rows.map(row => ({ id: eventId(row.id), type: row.type, entityId: row.entity_id ?? undefined, timestamp: eventTimestamp(row.timestamp), payload: row.payload })); + } +} + +export class PostgresCurrencyInventoryService implements CurrencyInventoryService { + readonly sql: SQL; + readonly ledger: EventLedgerStorage; + + constructor(options: PostgresBackendOptions & { ledger?: EventLedgerStorage } = {}) { + const url = options.url ?? process.env.DATABASE_URL; + if (!options.sql && !url) throw new Error('DATABASE_URL is required for Postgres storage'); + this.sql = options.sql ?? new SQL(url ?? ''); + this.ledger = options.ledger ?? new PostgresEventLedgerStorage({ sql: this.sql }); + } + + async getBalance(accountId: string): Promise { + const rows = await this.sql`SELECT balance FROM balances WHERE account_id = ${accountId}`; + return rows[0]?.balance ?? 0; + } + + async grant(accountId: string, amount: number, reason: string): Promise { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('grant amount must be a positive integer'); + const rows = await this.sql` + INSERT INTO balances (account_id, balance) VALUES (${accountId}, ${amount}) + ON CONFLICT (account_id) DO UPDATE SET balance = balances.balance + EXCLUDED.balance + RETURNING balance + `; + const balance = rows[0]?.balance; + if (balance === undefined) throw new Error('balance upsert returned no row'); + await this.sql` + INSERT INTO economy_ledger (account_id, event_type, amount, reason, balance, payload) + VALUES (${accountId}, 'currency_granted', ${amount}, ${reason}, ${balance}, ${JSON.stringify({ amount, reason, balance })}::jsonb) + `; + await this.ledger.append({ type: 'currency_granted', entityId: accountId, timestamp: new Date().toISOString(), payload: { amount, reason, balance } }); + return balance; + } + + async spend(accountId: string, amount: number, reason: string): Promise { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('spend amount must be a positive integer'); + return await this.sql.begin(async tx => { + const balanceRows = await tx`SELECT balance FROM balances WHERE account_id = ${accountId} FOR UPDATE`; + const balance = balanceRows[0]?.balance ?? 0; + if (balance < amount) throw new InsufficientFundsError(accountId, balance, amount); + const nextBalance = balance - amount; + await tx`UPDATE balances SET balance = ${nextBalance} WHERE account_id = ${accountId}`; + await tx` + INSERT INTO economy_ledger (account_id, event_type, amount, reason, balance, payload) + VALUES (${accountId}, 'currency_spent', ${amount}, ${reason}, ${nextBalance}, ${JSON.stringify({ amount, reason, balance: nextBalance })}::jsonb) + `; + await tx` + INSERT INTO agent_events (type, entity_id, timestamp, payload) + VALUES ('currency_spent', ${accountId}, ${new Date().toISOString()}, ${JSON.stringify({ amount, reason, balance: nextBalance })}::jsonb) + `; + return nextBalance; + }); + } + + async listOwnedItems(accountId: string): Promise { + const rows = await this.sql` + SELECT cosmetic_id FROM owned_items WHERE account_id = ${accountId} ORDER BY cosmetic_id ASC + `; + return rows.map(row => row.cosmetic_id); + } + + async grantOwnedItem(accountId: string, cosmeticId: string, reason: string): Promise { + const rows = await this.sql` + INSERT INTO owned_items (account_id, cosmetic_id) + VALUES (${accountId}, ${cosmeticId}) + ON CONFLICT (account_id, cosmetic_id) DO NOTHING + RETURNING cosmetic_id + `; + if (rows.length === 0) return; + + const payload = { cosmeticId, reason }; + await this.sql` + INSERT INTO economy_ledger (account_id, event_type, cosmetic_id, reason, payload) + VALUES (${accountId}, 'item_granted', ${cosmeticId}, ${reason}, ${JSON.stringify(payload)}::jsonb) + `; + await this.ledger.append({ type: 'item_granted', entityId: accountId, timestamp: new Date().toISOString(), payload }); + } +} + +export function createStorageServices(options: { sqlite?: SqliteBackendOptions; postgres?: PostgresBackendOptions } = {}): StorageServices { + const databaseUrl = options.postgres?.url ?? process.env.DATABASE_URL; + if (databaseUrl) { + const sql = options.postgres?.sql ?? new SQL(databaseUrl); + const eventLedger = new PostgresEventLedgerStorage({ sql }); + const currencyInventory = new PostgresCurrencyInventoryService({ sql, ledger: eventLedger }); + return { backend: 'postgres', currencyInventory, eventLedger }; + } + + const path = options.sqlite?.path ?? defaultDbPath; + const eventLedger = new SqliteEventLedgerStorage({ path }); + const currencyInventory = new SqliteCurrencyInventoryService({ path, ledger: eventLedger }); + return { backend: 'sqlite', currencyInventory, eventLedger }; +} diff --git a/src/contracts/sha256.test.ts b/src/contracts/sha256.test.ts new file mode 100644 index 0000000..ae7a6c4 --- /dev/null +++ b/src/contracts/sha256.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { sha256Hex } from './sha256.ts'; + +describe('sha256Hex', () => { + test('matches known vectors', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + }); + + test('matches node:crypto across lengths, multi-block and unicode inputs', () => { + const inputs = [ + 'a', + 'lunar pup', + 'x'.repeat(55), // padding boundary + 'x'.repeat(56), + 'x'.repeat(64), // exactly one block + 'x'.repeat(1000), // multi-block + '🐶 moon skating ünïcödé', + JSON.stringify({ kind: 'cosmetic', version: '1.0.0', nested: { a: [1, 2, 3] } }), + ]; + for (const input of inputs) { + const expected = createHash('sha256').update(input, 'utf8').digest('hex'); + expect(sha256Hex(input)).toBe(expected); + } + }); +}); diff --git a/src/contracts/sha256.ts b/src/contracts/sha256.ts new file mode 100644 index 0000000..5f74534 --- /dev/null +++ b/src/contracts/sha256.ts @@ -0,0 +1,75 @@ +/** + * Synchronous SHA-256 over a UTF-8 string, hex-encoded. + * + * Pure TypeScript so it runs identically in Bun (server/tests) and the + * browser bundle — `node:crypto` is unavailable in the browser and Web + * Crypto's digest is async, which would force every content-address + * computation to be async. Used for content-addressed package ids, not + * for anything performance- or attack-sensitive. + */ + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotr(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +export function sha256Hex(input: string): string { + const bytes = new TextEncoder().encode(input); + const bitLen = bytes.length * 8; + + // Padding: 0x80, zeros, 64-bit big-endian length. + const padded = new Uint8Array((((bytes.length + 8) >> 6) + 1) << 6); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000)); + view.setUint32(padded.length - 4, bitLen >>> 0); + + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4); + for (let i = 16; i < 64; i++) { + const w15 = w[i - 15]!; + const w2 = w[i - 2]!; + const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3); + const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10); + w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0; + } + + let [a, b, c, d, e, f, g, hh] = h as unknown as [number, number, number, number, number, number, number, number]; + for (let i = 0; i < 64; i++) { + const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + s1 + ch + K[i]! + w[i]!) >>> 0; + const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + maj) >>> 0; + hh = g; g = f; f = e; + e = (d + t1) >>> 0; + d = c; c = b; b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0]! + a) >>> 0; h[1] = (h[1]! + b) >>> 0; + h[2] = (h[2]! + c) >>> 0; h[3] = (h[3]! + d) >>> 0; + h[4] = (h[4]! + e) >>> 0; h[5] = (h[5]! + f) >>> 0; + h[6] = (h[6]! + g) >>> 0; h[7] = (h[7]! + hh) >>> 0; + } + + let out = ''; + for (let i = 0; i < 8; i++) out += h[i]!.toString(16).padStart(8, '0'); + return out; +} diff --git a/src/contracts/validators.ts b/src/contracts/validators.ts new file mode 100644 index 0000000..b79a91e --- /dev/null +++ b/src/contracts/validators.ts @@ -0,0 +1,62 @@ +export type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; +export type Validator = (value: unknown, path?: string) => ValidationResult; + +export function ok(value: T): ValidationResult { return { ok: true, value }; } +export function fail(error: string): ValidationResult { return { ok: false, error }; } + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readString(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'string' && value.length > 0 ? ok(value) : fail(`${path} must be a non-empty string`); +} + +export function readOptionalString(obj: Record, key: string): ValidationResult { + const value = obj[key]; + return value === undefined || typeof value === 'string' ? ok(value) : fail(`${key} must be a string`); +} + +export function readNumber(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'number' && Number.isFinite(value) ? ok(value) : fail(`${path} must be a finite number`); +} + +export function readBoolean(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'boolean' ? ok(value) : fail(`${path} must be a boolean`); +} + +export function readEnum(obj: Record, key: string, values: T, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'string' && values.includes(value) ? ok(value as T[number]) : fail(`${path} must be one of ${values.join(', ')}`); +} + +export function readArray(obj: Record, key: string, item: Validator, path = key): ValidationResult { + const value = obj[key]; + if (!Array.isArray(value)) return fail(`${path} must be an array`); + const out: T[] = []; + for (let i = 0; i < value.length; i++) { + const parsed = item(value[i], `${path}[${i}]`); + if (!parsed.ok) return parsed; + out.push(parsed.value); + } + return ok(out); +} + +export function readRecordOfStrings(obj: Record, key: string, path = key): ValidationResult> { + const value = obj[key]; + if (!isRecord(value)) return fail(`${path} must be an object`); + const out: Record = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + if (typeof entryValue !== 'string' || entryValue.length === 0) return fail(`${path}.${entryKey} must be a non-empty string`); + out[entryKey] = entryValue; + } + return ok(out); +} + +export function assertValid(result: ValidationResult): T { + if (!result.ok) throw new Error(result.error); + return result.value; +} diff --git a/src/cosmetics/registry.ts b/src/cosmetics/registry.ts new file mode 100644 index 0000000..8f1d55f --- /dev/null +++ b/src/cosmetics/registry.ts @@ -0,0 +1,104 @@ +import { sha256Hex } from '../contracts/sha256.ts'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { validateCosmeticDefinition, type CosmeticDefinition, type CosmeticSlot } from '../contracts/cosmetic.ts'; +import { validatePackageManifest, type PackageManifest } from '../contracts/packageManifest.ts'; + +export interface CosmeticPackage { + id: string; + manifest: PackageManifest; + definition: CosmeticDefinition; + price: number; +} + +export interface EquippedCosmetics { + board?: string; + body?: string; + trail?: string; + aura?: string; +} + +export const cosmeticContentDir = 'content/cosmetics'; + +const manifestFile = 'manifest.json'; +const definitionAssetName = 'definition'; + +let cachedPackages: CosmeticPackage[] | null = null; + +export function clearCosmeticRegistryCache(): void { + cachedPackages = null; +} + +export async function loadCosmeticCatalog(rootDir = cosmeticContentDir): Promise { + if (rootDir === cosmeticContentDir && cachedPackages) return cachedPackages; + + const entries = await readdir(rootDir, { withFileTypes: true }); + const packages = await Promise.all(entries + .filter(entry => entry.isDirectory()) + .map(entry => loadCosmeticPackage(join(rootDir, entry.name)))); + packages.sort((a, b) => a.definition.slot.localeCompare(b.definition.slot) || a.price - b.price || a.manifest.displayName.localeCompare(b.manifest.displayName)); + + if (rootDir === cosmeticContentDir) cachedPackages = packages; + return packages; +} + +export async function loadCosmeticPackage(packageDir: string): Promise { + const manifest = parseJson(await readFile(join(packageDir, manifestFile), 'utf8'), `${packageDir}/${manifestFile}`); + const validatedManifest = validatePackageManifest(manifest); + if (!validatedManifest.ok) throw new Error(`${packageDir}/${manifestFile}: ${validatedManifest.error}`); + if (validatedManifest.value.kind !== 'cosmetic') throw new Error(`${packageDir}/${manifestFile}: kind must be cosmetic`); + + const definitionRef = validatedManifest.value.assetRefs.find(ref => ref.name === definitionAssetName); + if (!definitionRef) throw new Error(`${packageDir}/${manifestFile}: missing definition asset ref`); + if (definitionRef.uri.includes('://') || definitionRef.uri.startsWith('/') || definitionRef.uri.includes('..')) { + throw new Error(`${packageDir}/${manifestFile}: definition asset must be a relative package file`); + } + + const definitionJson = await readFile(join(packageDir, definitionRef.uri), 'utf8'); + const actualSha = sha256Hex(definitionJson); + if (actualSha !== definitionRef.sha256) { + throw new Error(`${packageDir}/${definitionRef.uri}: sha256 mismatch (${actualSha})`); + } + + const definitionResult = validateCosmeticDefinition(parseJson(definitionJson, `${packageDir}/${definitionRef.uri}`)); + if (!definitionResult.ok) throw new Error(`${packageDir}/${definitionRef.uri}: ${definitionResult.error}`); + + const price = Number(validatedManifest.value.metadata?.price ?? 0); + if (!Number.isInteger(price) || price < 0) throw new Error(`${packageDir}/${manifestFile}: metadata.price must be a non-negative integer`); + + return { + id: validatedManifest.value.id, + manifest: validatedManifest.value, + definition: { ...definitionResult.value, id: validatedManifest.value.id }, + price, + }; +} + +export async function getCosmeticById(id: string, rootDir = cosmeticContentDir): Promise { + return (await loadCosmeticCatalog(rootDir)).find(pkg => pkg.id === id); +} + +export function sanitizeEquippedCosmetics(value: unknown): EquippedCosmetics { + if (!value || typeof value !== 'object') return {}; + const record = value as Record; + const equipped: EquippedCosmetics = {}; + for (const slot of ['board', 'body', 'trail', 'aura'] as const) { + if (typeof record[slot] === 'string' && record[slot].length > 0) equipped[slot] = record[slot] as string; + } + return equipped; +} + +export function equippedWith(slot: CosmeticSlot, cosmeticId: string | null, current: EquippedCosmetics): EquippedCosmetics { + const next = { ...current }; + if (cosmeticId) next[slot] = cosmeticId; + else delete next[slot]; + return next; +} + +function parseJson(source: string, label: string): unknown { + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`${label}: invalid JSON (${error instanceof Error ? error.message : String(error)})`); + } +} diff --git a/src/game/bootstrap.ts b/src/game/bootstrap.ts index 1650e36..d52c9e9 100644 --- a/src/game/bootstrap.ts +++ b/src/game/bootstrap.ts @@ -21,6 +21,9 @@ export async function bootstrap() { { setupMinimap }, { setupChatUI }, { setupUpdateNotice }, + { setupAgentHud }, + { setupCosmeticsUI }, + { setupGamemodeUI }, ] = await Promise.all([ import('./scene.ts'), import('./terrain.ts'), @@ -34,6 +37,9 @@ export async function bootstrap() { import('../ui/minimap.ts'), import('../ui/chat.ts'), import('../ui/updateNotice.ts'), + import('../ui/agentHud.ts'), + import('../ui/cosmetics.ts'), + import('../modes/client.ts'), ]); initScene(container); @@ -50,10 +56,26 @@ export async function bootstrap() { setSpeedLines(setupSpeedLines()); setupTrickUI(); setupTuningPanel(); - setupMultiplayerUI(); + setupMultiplayerUI({ + enabled: mpConfig.enabled, + room: mpConfig.room, + name: mpConfig.name, + apiBase: mpConfig.apiBase, + wsUrl: mpConfig.wsUrl, + onJoinRoom: (roomId) => { + const url = new URL(location.href); + url.searchParams.set('multiplayer', ''); + url.searchParams.set('room', roomId); + if (!url.searchParams.has('name')) url.searchParams.set('name', mpConfig.name); + location.href = url.href; + }, + }); setupMinimap(); setupChatUI(mpConfig.enabled, mpConfig.name); + setupCosmeticsUI(); setupUpdateNotice(); + setupAgentHud(); + setupGamemodeUI(); if (mpConfig.enabled) { if (mpConfig.transport === 'ws' && !mpConfig.wsUrl) { diff --git a/src/game/cosmetics.ts b/src/game/cosmetics.ts new file mode 100644 index 0000000..a31726c --- /dev/null +++ b/src/game/cosmetics.ts @@ -0,0 +1,125 @@ +import * as THREE from 'three'; +import { dog, playerGroup, scene, skateboard } from '../state.ts'; +import type { CosmeticPackage, EquippedCosmetics } from '../cosmetics/registry.ts'; +import { registerUpdateHook } from './loop.ts'; +import type { VoxelDogParts } from './player.ts'; + +interface AppliedCosmeticHandle { + dispose(): void; +} + +const localHandles: AppliedCosmeticHandle[] = []; +let knownCatalog: CosmeticPackage[] = []; + +export function setKnownCosmeticCatalog(catalog: CosmeticPackage[]): void { + knownCatalog = catalog; +} + +const remoteHandles = new WeakMap(); + +export function applyLocalCosmetics(equipped: EquippedCosmetics, catalog: CosmeticPackage[]): void { + setKnownCosmeticCatalog(catalog); + disposeHandles(localHandles); + localHandles.push(...buildCosmeticHandles({ group: playerGroup, dog, skateboard }, equipped, catalog, true)); +} + +export function applyRemoteCosmetics(parts: VoxelDogParts, equipped: EquippedCosmetics | undefined, catalog = knownCatalog): void { + const previous = remoteHandles.get(parts.group) ?? []; + disposeHandles(previous); + const next = buildCosmeticHandles(parts, equipped ?? {}, catalog, false); + remoteHandles.set(parts.group, next); +} + +function buildCosmeticHandles(parts: Pick, equipped: EquippedCosmetics, catalog: CosmeticPackage[], animate: boolean): AppliedCosmeticHandle[] { + const byId = new Map(catalog.map(pkg => [pkg.id, pkg])); + const handles: AppliedCosmeticHandle[] = []; + const board = equipped.board ? byId.get(equipped.board) : undefined; + const body = equipped.body ? byId.get(equipped.body) : undefined; + const trail = equipped.trail ? byId.get(equipped.trail) : undefined; + const aura = equipped.aura ? byId.get(equipped.aura) : undefined; + + if (board) handles.push(recolorMeshes(parts.skateboard, Number.parseInt(board.definition.visual.colors[0]!.slice(1, 7), 16), 'deck')); + if (body) handles.push(recolorMeshes(parts.dog, Number.parseInt(body.definition.visual.colors[0]!.slice(1, 7), 16), 'fur')); + if (trail) handles.push(addTrail(parts.group, trail, animate)); + if (aura) handles.push(addAura(parts.group, aura, animate)); + return handles; +} + +function recolorMeshes(root: THREE.Group, color: number, dogPart?: string): AppliedCosmeticHandle { + const originals: Array<{ material: THREE.MeshStandardMaterial; color: THREE.Color }> = []; + root.traverse(obj => { + if (!(obj instanceof THREE.Mesh)) return; + if (dogPart && obj.userData.dogPart !== dogPart) return; + const material = obj.material; + if (!(material instanceof THREE.MeshStandardMaterial)) return; + originals.push({ material, color: material.color.clone() }); + material.color.setHex(color); + }); + return { dispose: () => originals.forEach(original => original.material.color.copy(original.color)) }; +} + +function addTrail(group: THREE.Group, cosmetic: CosmeticPackage, animate: boolean): AppliedCosmeticHandle { + const particleSpec = cosmetic.definition.visual.particles ?? { count: 24, size: 0.08, lifetime: 0.6, emissionRate: 16 }; + const color = new THREE.Color(cosmetic.definition.visual.colors[0]); + const dots: THREE.Mesh[] = []; + const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.55, depthWrite: false }); + const geometry = new THREE.SphereGeometry(particleSpec.size, 8, 6); + const root = new THREE.Group(); + root.position.set(0, 0.35, -2.25); + for (let index = 0; index < Math.min(particleSpec.count, 48); index += 1) { + const dot = new THREE.Mesh(geometry, material.clone()); + dot.position.set((Math.random() - 0.5) * 0.8, Math.random() * 0.25, -index * 0.12); + dot.userData.age = index / Math.max(1, particleSpec.count); + dots.push(dot); + root.add(dot); + } + group.add(root); + + const unregister = animate ? registerUpdateHook((dt) => { + for (const dot of dots) { + dot.userData.age = (dot.userData.age + dt * particleSpec.emissionRate * 0.08) % 1; + dot.position.z = -0.3 - dot.userData.age * particleSpec.lifetime * 4; + const dotMaterial = dot.material; + if (dotMaterial instanceof THREE.MeshBasicMaterial) dotMaterial.opacity = 0.65 * (1 - dot.userData.age); + } + }) : undefined; + + return { + dispose() { + unregister?.(); + group.remove(root); + geometry.dispose(); + material.dispose(); + for (const dot of dots) if (dot.material instanceof THREE.Material) dot.material.dispose(); + }, + }; +} + +function addAura(group: THREE.Group, cosmetic: CosmeticPackage, animate: boolean): AppliedCosmeticHandle { + const mesh = cosmetic.definition.visual.mesh; + const color = new THREE.Color(cosmetic.definition.visual.colors[0]); + const geometry = new THREE.TorusGeometry(mesh?.scale[0] ?? 1.3, mesh?.scale[1] ?? 0.08, 8, 32); + const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.4, depthWrite: false }); + const aura = new THREE.Mesh(geometry, material); + aura.rotation.x = Math.PI / 2; + aura.position.y = 1.05; + group.add(aura); + + const unregister = animate ? registerUpdateHook((dt) => { + aura.rotation.z += dt * 1.6; + material.opacity = 0.32 + Math.sin(Date.now() * 0.004) * 0.1; + }) : undefined; + + return { + dispose() { + unregister?.(); + group.remove(aura); + geometry.dispose(); + material.dispose(); + }, + }; +} + +function disposeHandles(handles: AppliedCosmeticHandle[]): void { + for (const handle of handles.splice(0)) handle.dispose(); +} diff --git a/src/game/loop.ts b/src/game/loop.ts index 2568b64..ead5779 100644 --- a/src/game/loop.ts +++ b/src/game/loop.ts @@ -24,6 +24,37 @@ import { updateMinimap } from '../ui/minimap.ts'; import { updateRemotePlayers } from './remotePlayers.ts'; import { buildLocalSnapshot } from './multiplayer.ts'; import { finishTrick, startTrick, updateTrick } from './tricks.ts'; +import type { Gamemode, GamemodeRuntimeState } from '../contracts/gamemode.ts'; + +export interface GameLoopState { + playerGroup: typeof playerGroup; + physics: typeof physics; + scene: typeof scene; + skateboard: typeof skateboard; +} + +export type UpdateHook = (dt: number, state: GameLoopState) => void; + +const updateHooks = new Set(); +let currentGamemode: { gamemode: Gamemode; state: GamemodeRuntimeState } | null = null; + +export function registerUpdateHook(fn: UpdateHook): () => void { + updateHooks.add(fn); + return () => updateHooks.delete(fn); +} + +export function setCurrentGamemode(gamemode: Gamemode | null, state?: GamemodeRuntimeState): void { + if (!gamemode) { + currentGamemode = null; + return; + } + if (!state) throw new Error('state is required when setting a gamemode'); + currentGamemode = { gamemode, state }; +} + +export function getCurrentGamemode(): Gamemode | null { + return currentGamemode?.gamemode ?? null; +} export function setupCameraControls() { const canvas = renderer.domElement; @@ -196,6 +227,13 @@ export function startGameLoop() { updateRemotePlayers(dt); updateMinimap(); + const loopState: GameLoopState = { playerGroup, physics, scene, skateboard }; + for (const hook of updateHooks) hook(dt, loopState); + if (currentGamemode) { + currentGamemode.state.elapsedMs += dt * 1000; + void currentGamemode.gamemode.tick(dt, currentGamemode.state); + } + if (multiplayerClient?.isConnected) { multiplayerClient.sendState(buildLocalSnapshot( playerGroup, diff --git a/src/game/multiplayer.ts b/src/game/multiplayer.ts index bbc744c..b746e77 100644 --- a/src/game/multiplayer.ts +++ b/src/game/multiplayer.ts @@ -1,5 +1,6 @@ import { MultiplayerClient } from '../net/client.ts'; import type { PlayerSnapshot } from '../net/protocol.ts'; +import type { EquippedCosmetics } from '../cosmetics/registry.ts'; import { addRemotePlayer, clearRemotePlayers, @@ -7,11 +8,17 @@ import { updateRemoteTarget, getRemotePlayerNames, } from './remotePlayers.ts'; -import { updateMultiplayerStatus, updateMultiplayerPlayers } from '../ui/multiplayer.ts'; +import { updateMultiplayerStatus, updateMultiplayerPlayers, updateRoomBrowser, updateMultiplayerHint } from '../ui/multiplayer.ts'; import { appendChatMessage } from '../ui/chat.ts'; import { setMultiplayerClient } from '../state.ts'; import { tintLocalDog } from './player.ts'; +let localEquippedCosmetics: EquippedCosmetics | undefined; + +export function setLocalEquippedCosmetics(equipped: EquippedCosmetics): void { + localEquippedCosmetics = { ...equipped }; +} + export function initMultiplayer(config: { transport: 'ws' | 'http'; wsUrl?: string | null; @@ -34,6 +41,8 @@ export function initMultiplayer(config: { tintLocalDog(color); for (const player of players) addRemotePlayer(player); updateMultiplayerPlayers(localName, getRemotePlayerNames()); + client.joinLobbyRoom(config.room, id); + client.listRooms(); }, onPlayerJoined: (player) => { addRemotePlayer(player); @@ -49,6 +58,19 @@ export function initMultiplayer(config: { onChat: (id, name, text) => { appendChatMessage(id, name, text, id === client.id); }, + onRoomList: (rooms) => { + updateRoomBrowser(rooms); + }, + onRoomState: (roomId, gamemodeId, players) => { + if (roomId === config.room) { + updateMultiplayerHint(`${gamemodeId} lobby · ${players.length} pup${players.length === 1 ? '' : 's'} ready.`); + } + }, + onGamemodeStart: (roomId, gamemodeId, hostId) => { + if (roomId === config.room) { + appendChatMessage(hostId, 'Host', `started ${gamemodeId}`, false); + } + }, }); setMultiplayerClient(client); @@ -81,5 +103,6 @@ export function buildLocalSnapshot( isGrounded, boardTiltX, boardTiltZ, + cosmetics: localEquippedCosmetics, }; } diff --git a/src/game/player.ts b/src/game/player.ts index 2e8311a..fdc2b64 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -17,7 +17,7 @@ export interface VoxelDogParts { tail: THREE.Mesh; } -export function createVoxelDog(dogColor: number = PLAYER_COLORS[0], deckColor = 0xff5555): VoxelDogParts { +export function createVoxelDog(dogColor: number = PLAYER_COLORS[0] ?? 0xffb703, deckColor: number = 0xff5555): VoxelDogParts { const group = new THREE.Group(); const skateboard = new THREE.Group(); @@ -25,6 +25,7 @@ export function createVoxelDog(dogColor: number = PLAYER_COLORS[0], deckColor = const deckMat = new THREE.MeshStandardMaterial({ color: deckColor, roughness: 0.5 }); const deck = new THREE.Mesh(deckGeom, deckMat); deck.position.y = 0.3; + deck.userData.dogPart = 'deck'; deck.castShadow = true; skateboard.add(deck); diff --git a/src/game/remotePlayers.ts b/src/game/remotePlayers.ts index 97fea36..5f8929f 100644 --- a/src/game/remotePlayers.ts +++ b/src/game/remotePlayers.ts @@ -2,6 +2,7 @@ import * as THREE from 'three'; import type { PlayerSnapshot } from '../net/protocol.ts'; import { scene } from '../state.ts'; import { createVoxelDog } from './player.ts'; +import { applyRemoteCosmetics } from './cosmetics.ts'; interface RemotePlayer { id: string; @@ -26,6 +27,7 @@ export function addRemotePlayer(player: PlayerSnapshot) { parts.skateboard.rotation.x = player.boardTiltX; parts.skateboard.rotation.z = player.boardTiltZ; scene.add(parts.group); + applyRemoteCosmetics(parts, player.cosmetics); remotePlayers.set(player.id, { id: player.id, @@ -47,6 +49,7 @@ export function updateRemoteTarget(id: string, state: Omit void) | null = null; +let checkpointRoot: THREE.Group | null = null; +let sampleSocket: WebSocket | null = null; +let lastSampleMs = 0; +let samples: RunSample[] = []; +let resultsVisible = false; + +export function setupGamemodeUI(): void { + const packages = gamemodePackages.map(pkg => validateGamemodePackage(pkg)); + const panel = document.createElement('div'); + panel.id = 'gamemode-panel'; + panel.innerHTML = `

Gamemodes

Free skate
`; + document.body.appendChild(panel); + const buttons = document.getElementById('gamemode-buttons'); + if (!buttons) return; + for (const pkg of packages) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = pkg.manifest.displayName; + button.addEventListener('click', () => startGamemode(pkg)); + buttons.appendChild(button); + } + const results = document.createElement('div'); + results.id = 'gamemode-results'; + results.hidden = true; + document.body.appendChild(results); +} + +export function startGamemode(pkg: GamemodePackageDefinition): void { + stopGamemode(); + activePackage = validateGamemodePackage(pkg); + const snapshot = localPlayerSnapshot(); + const start = activePackage.params.startPosition; + playerGroup.position.set(start.x, start.y || getTerrainHeight(start.x, start.z) + groundClearance, start.z); + physics.speed = 0; + physics.velocity.set(0, 0, 0); + physics.isGrounded = true; + snapshot.x = playerGroup.position.x; + snapshot.y = playerGroup.position.y; + snapshot.z = playerGroup.position.z; + activeState = createRuntimeState(activePackage.params, snapshot); + const gamemode = createGamemode(activePackage, event => { + if (event.type === 'finish') showResults(); + }); + void gamemode.init({ roomId: 'local', now: () => performance.now(), broadcast: () => undefined }); + void gamemode.start(activeState); + setCurrentGamemode(gamemode, activeState); + checkpointRoot = buildGamemodeMeshes(activePackage); + scene.add(checkpointRoot); + samples = []; + lastSampleMs = 0; + resultsVisible = false; + openSampleSocket(); + unregisterHook = registerUpdateHook((dt) => updateGamemode(dt)); + updateStatus(); +} + +export function stopGamemode(): void { + if (unregisterHook) unregisterHook(); + unregisterHook = null; + setCurrentGamemode(null); + if (checkpointRoot) { + scene.remove(checkpointRoot); + checkpointRoot.traverse(object => { + if (object instanceof THREE.Mesh) { + object.geometry.dispose(); + const material = object.material; + if (Array.isArray(material)) { + for (const entry of material) entry.dispose(); + } else { + material.dispose(); + } + } + }); + } + checkpointRoot = null; + flushRunSamples('abandon'); + activeState = null; + activePackage = null; +} + +function updateGamemode(dt: number): void { + if (!activeState || !activePackage) return; + const player = activeState.players.get('local'); + if (player) copyLocalIntoSnapshot(player); + if (activePackage.params.type === 'parkour') applyParkourPlatformGrounding(activePackage.params.platforms ?? []); + sampleRun(dt); + updateCheckpointVisuals(); + updateStatus(); + if (activeState.ended && !resultsVisible) showResults(); +} + +function buildGamemodeMeshes(pkg: GamemodePackageDefinition): THREE.Group { + const root = new THREE.Group(); + const gateMaterial = new THREE.MeshBasicMaterial({ color: 0x80ff72, wireframe: true, transparent: true, opacity: 0.7 }); + const inactiveMaterial = new THREE.MeshBasicMaterial({ color: 0xa0c4ff, wireframe: true, transparent: true, opacity: 0.35 }); + for (const checkpoint of orderedCheckpoints(pkg.params)) { + const geometry = new THREE.TorusGeometry(checkpoint.radius, 0.65, 8, 40); + const mesh = new THREE.Mesh(geometry, checkpoint.order === 0 ? gateMaterial.clone() : inactiveMaterial.clone()); + mesh.position.set(checkpoint.position.x, checkpoint.position.y, checkpoint.position.z); + mesh.rotation.y = Math.PI / 2; + mesh.userData.checkpointId = checkpoint.id; + root.add(mesh); + } + if (pkg.params.platforms) { + const material = new THREE.MeshStandardMaterial({ color: 0x6c63ff, roughness: 0.7, metalness: 0.15 }); + for (const platform of pkg.params.platforms) { + const geometry = new THREE.BoxGeometry(platform.size.x, platform.size.y, platform.size.z); + const mesh = new THREE.Mesh(geometry, material.clone()); + mesh.position.set(platform.position.x, platform.position.y, platform.position.z); + mesh.castShadow = true; + mesh.receiveShadow = true; + root.add(mesh); + } + } + return root; +} + +function updateCheckpointVisuals(): void { + if (!checkpointRoot || !activeState || !activePackage) return; + const progress = activeState.progress.get('local'); + if (!progress) return; + const checkpoints = orderedCheckpoints(activePackage.params); + const active = checkpoints[progress.nextCheckpointIndex]; + checkpointRoot.traverse(object => { + if (!(object instanceof THREE.Mesh)) return; + if (!('checkpointId' in object.userData)) return; + const material = object.material; + if (Array.isArray(material)) return; + material.color.set(object.userData.checkpointId === active?.id ? 0x80ff72 : 0xa0c4ff); + material.opacity = object.userData.checkpointId === active?.id ? 0.8 : 0.25; + }); +} + +function applyParkourPlatformGrounding(platforms: PlatformDefinition[]): void { + for (const platform of platforms) { + const halfX = platform.size.x / 2; + const halfZ = platform.size.z / 2; + const top = platform.position.y + platform.size.y / 2 + groundClearance; + const insideX = Math.abs(playerGroup.position.x - platform.position.x) <= halfX; + const insideZ = Math.abs(playerGroup.position.z - platform.position.z) <= halfZ; + if (insideX && insideZ && playerGroup.position.y <= top + 4 && physics.velocity.y <= 0) { + playerGroup.position.y = top; + physics.velocity.y = 0; + physics.isGrounded = true; + } + } +} + +function sampleRun(dt: number): void { + if (!activeState || !activePackage) return; + lastSampleMs += dt * 1000; + if (lastSampleMs < 100) return; + lastSampleMs = 0; + samples.push({ t: Math.round(activeState.elapsedMs), x: playerGroup.position.x, y: playerGroup.position.y, z: playerGroup.position.z, speed: physics.speed }); + if (samples.length >= 50) flushRunSamples('sample'); +} + +function flushRunSamples(reason: 'sample' | 'finish' | 'abandon'): void { + if (!activePackage || samples.length === 0) return; + const payload = { + channel: 'gamemode', + type: 'run_sample', + gamemodeId: activePackage.manifest.id, + playerId: 'local', + reason, + samples, + }; + if (sampleSocket?.readyState === WebSocket.OPEN) sampleSocket.send(JSON.stringify(payload)); + samples = []; +} + +function openSampleSocket(): void { + if (sampleSocket && sampleSocket.readyState <= WebSocket.OPEN) return; + const url = new URL(window.location.href); + const explicit = url.searchParams.get('ws'); + const target = explicit || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ? `ws://${window.location.hostname}:3001` : null); + if (!target) return; + sampleSocket = new WebSocket(target); +} + +function showResults(): void { + if (!activeState || !activePackage) return; + resultsVisible = true; + flushRunSamples('finish'); + const result = activeState.results.find(entry => entry.playerId === 'local'); + const progress = activeState.progress.get('local'); + const elapsed = formatTime(progress?.finishedAtMs ?? activeState.elapsedMs); + const bestLap = result?.bestLapMs ? formatTime(result.bestLapMs) : '—'; + const results = document.getElementById('gamemode-results'); + if (!results) return; + results.hidden = false; + results.innerHTML = `

${activePackage.manifest.displayName}

Finished in ${elapsed}

Score ${result?.score ?? 0}

Best lap ${bestLap}

`; + document.getElementById('gamemode-close-results')?.addEventListener('click', () => { results.hidden = true; }); +} + +function updateStatus(): void { + const status = document.getElementById('gamemode-status'); + if (!status || !activeState || !activePackage) { + if (status) status.textContent = 'Free skate'; + return; + } + const progress = activeState.progress.get('local'); + const score = activeState.scores.get('local') ?? 0; + const total = activePackage.params.checkpoints.length; + const next = progress ? progress.nextCheckpointIndex + 1 : 1; + status.textContent = `${activePackage.manifest.displayName}: gate ${next}/${total} lap ${progress?.lap ?? 0} score ${score} time ${formatTime(activeState.elapsedMs)}`; +} + +function localPlayerSnapshot(): PlayerSnapshot { + const state = buildLocalSnapshot(playerGroup, physics.heading, physics.speed, physics.isGrounded, skateboard.rotation.x, skateboard.rotation.z); + return { id: 'local', name: 'Local Pup', color: 0xffb703, ...state }; +} + +function copyLocalIntoSnapshot(player: PlayerSnapshot): void { + const snapshot = localPlayerSnapshot(); + player.x = snapshot.x; + player.y = snapshot.y; + player.z = snapshot.z; + player.qx = snapshot.qx; + player.qy = snapshot.qy; + player.qz = snapshot.qz; + player.qw = snapshot.qw; + player.heading = snapshot.heading; + player.speed = snapshot.speed; + player.isGrounded = snapshot.isGrounded; + player.boardTiltX = snapshot.boardTiltX; + player.boardTiltZ = snapshot.boardTiltZ; +} + +function formatTime(ms: number): string { + const seconds = ms / 1000; + return `${seconds.toFixed(2)}s`; +} diff --git a/src/modes/runtime.test.ts b/src/modes/runtime.test.ts new file mode 100644 index 0000000..2cf193c --- /dev/null +++ b/src/modes/runtime.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import type { PlayerSnapshot } from '../net/protocol.ts'; +import { gamemodePackages, parkourGamemodePackage, raceGamemodePackage } from '../../content/gamemodes/index.ts'; +import { createGamemode, createRuntimeState, maybeResetFallenPlayer, processCheckpoint, validateGamemodePackage } from './runtime.ts'; + +function playerAt(id: string, x: number, y: number, z: number): PlayerSnapshot { + return { + id, + name: id, + color: 0xffb703, + x, + y, + z, + qx: 0, + qy: 0, + qz: 0, + qw: 1, + heading: 0, + speed: 0, + isGrounded: true, + boardTiltX: 0, + boardTiltZ: 0, + }; +} + +describe('gamemode package validation', () => { + test('sample packages satisfy manifest contract and params', () => { + for (const pkg of gamemodePackages) { + expect(validateGamemodePackage(pkg).manifest.kind).toBe('gamemode'); + } + }); + + test('rejects tampered manifest ids', () => { + expect(() => validateGamemodePackage({ + ...raceGamemodePackage, + manifest: { ...raceGamemodePackage.manifest, displayName: 'Wrong' }, + })).toThrow('id must be sha256'); + }); +}); + +describe('checkpoint ordering', () => { + test('only advances the next ordered checkpoint', () => { + const first = raceGamemodePackage.params.checkpoints[0]!; + const second = raceGamemodePackage.params.checkpoints[1]!; + const player = playerAt('p1', second.position.x, second.position.y, second.position.z); + const state = createRuntimeState(raceGamemodePackage.params, player); + + expect(processCheckpoint(state, player)).toBe(false); + expect(state.progress.get('p1')?.nextCheckpointIndex).toBe(0); + + player.x = first.position.x; + player.y = first.position.y; + player.z = first.position.z; + expect(processCheckpoint(state, player)).toBe(true); + expect(state.progress.get('p1')?.nextCheckpointIndex).toBe(1); + }); +}); + +describe('scoring and win conditions', () => { + test('race finishes after configured laps and records best lap', () => { + const player = playerAt('p1', 0, 0, 0); + const state = createRuntimeState(raceGamemodePackage.params, player); + const mode = createGamemode(raceGamemodePackage); + void mode.init({ roomId: 'test', now: () => 0, broadcast: () => undefined }); + void mode.start(state); + + for (let lap = 0; lap < raceGamemodePackage.params.laps!; lap += 1) { + for (const checkpoint of raceGamemodePackage.params.checkpoints) { + state.elapsedMs += 1_000; + player.x = checkpoint.position.x; + player.y = checkpoint.position.y; + player.z = checkpoint.position.z; + void mode.tick(1, state); + } + } + + expect(mode.isWinConditionMet(state)).toBe(true); + const progress = state.progress.get('p1'); + expect(progress?.finishedAtMs).toBe(8_000); + expect(progress?.bestLapMs).toBe(4_000); + expect(mode.score('p1', state)).toBeGreaterThan(100_000); + }); + + test('parkour fall resets to last checkpoint and penalizes score', () => { + const first = parkourGamemodePackage.params.checkpoints[0]!; + const player = playerAt('p1', first.position.x, first.position.y, first.position.z); + const state = createRuntimeState(parkourGamemodePackage.params, player); + state.elapsedMs = 1_000; + expect(processCheckpoint(state, player)).toBe(true); + player.y = -100; + + expect(maybeResetFallenPlayer(state, player)).toBe(true); + expect(player.x).toBe(first.position.x); + expect(player.y).toBe(first.position.y); + expect(player.z).toBe(first.position.z); + expect(state.progress.get('p1')?.falls).toBe(1); + expect(state.scores.get('p1')).toBe(490); + }); +}); diff --git a/src/modes/runtime.ts b/src/modes/runtime.ts new file mode 100644 index 0000000..4320ebe --- /dev/null +++ b/src/modes/runtime.ts @@ -0,0 +1,299 @@ +import type { Gamemode, GamemodeContext, GamemodeRuntimeState, CheckpointDefinition } from '../contracts/gamemode.ts'; +import type { PackageManifest } from '../contracts/packageManifest.ts'; +import { validatePackageManifest } from '../contracts/packageManifest.ts'; +import type { PlayerSnapshot } from '../net/protocol.ts'; + +export type GamemodeType = 'race' | 'parkour'; + +export interface GamemodePackageDefinition { + manifest: PackageManifest; + params: GamemodeParams; +} + +export interface GamemodeParams { + type: GamemodeType; + laps?: number; + timeLimitMs?: number; + checkpoints: CheckpointDefinition[]; + startPosition: { x: number; y: number; z: number }; + platforms?: PlatformDefinition[]; + fallY?: number; +} + +export interface PlatformDefinition { + id: string; + position: { x: number; y: number; z: number }; + size: { x: number; y: number; z: number }; +} + +export interface PlayerProgress { + nextCheckpointIndex: number; + completedCheckpoints: number; + lap: number; + bestLapMs?: number; + lapStartedAtMs: number; + finishedAtMs?: number; + lastCheckpointId?: string; + lastCheckpointPosition: { x: number; y: number; z: number }; + falls: number; +} + +export interface GamemodeResult { + playerId: string; + score: number; + completedAtMs?: number; + bestLapMs?: number; + falls: number; +} + +export interface RuntimeGamemodeState extends GamemodeRuntimeState { + params: GamemodeParams; + progress: Map; + results: GamemodeResult[]; + startedAtMs: number; + ended: boolean; +} + +export interface GamemodeRuntimeEvent { + type: 'checkpoint' | 'lap' | 'finish' | 'fall' | 'reset'; + playerId: string; + checkpointId?: string; + elapsedMs: number; + score: number; +} + +const emptyPlayerSnapshot: PlayerSnapshot = { + id: 'local', + name: 'Local Pup', + color: 0xffb703, + x: 0, + y: 0, + z: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 1, + heading: 0, + speed: 0, + isGrounded: true, + boardTiltX: 0, + boardTiltZ: 0, +}; + +export function validateGamemodePackage(pkg: GamemodePackageDefinition): GamemodePackageDefinition { + const manifest = validatePackageManifest(pkg.manifest); + if (!manifest.ok) throw new Error(manifest.error); + if (manifest.value.kind !== 'gamemode') throw new Error('manifest kind must be gamemode'); + validateGamemodeParams(pkg.params); + return { manifest: manifest.value, params: pkg.params }; +} + +export function validateGamemodeParams(params: GamemodeParams): void { + if (params.type !== 'race' && params.type !== 'parkour') throw new Error('gamemode type must be race or parkour'); + if (!Array.isArray(params.checkpoints) || params.checkpoints.length === 0) throw new Error('gamemode requires checkpoints'); + const ids = new Set(); + for (const checkpoint of params.checkpoints) { + if (!checkpoint.id) throw new Error('checkpoint id is required'); + if (ids.has(checkpoint.id)) throw new Error(`duplicate checkpoint id ${checkpoint.id}`); + ids.add(checkpoint.id); + if (!(checkpoint.radius > 0)) throw new Error(`checkpoint ${checkpoint.id} radius must be positive`); + } + if (params.type === 'race' && (!Number.isInteger(params.laps) || (params.laps ?? 0) < 1)) throw new Error('race laps must be a positive integer'); + if (params.type === 'parkour' && (!params.platforms || params.platforms.length === 0)) throw new Error('parkour requires platforms'); +} + +export function createRuntimeState(params: GamemodeParams, localPlayer: PlayerSnapshot = emptyPlayerSnapshot): RuntimeGamemodeState { + const state: RuntimeGamemodeState = { + players: new Map([[localPlayer.id, localPlayer]]), + scores: new Map([[localPlayer.id, 0]]), + checkpoints: params.checkpoints, + elapsedMs: 0, + params, + progress: new Map(), + results: [], + startedAtMs: 0, + ended: false, + }; + state.progress.set(localPlayer.id, createInitialProgress(params)); + return state; +} + +export function createGamemode(pkg: GamemodePackageDefinition, onEvent?: (event: GamemodeRuntimeEvent) => void): Gamemode { + const valid = validateGamemodePackage(pkg); + let context: GamemodeContext | null = null; + const params = valid.params; + + function emit(state: RuntimeGamemodeState, event: Omit) { + const full = { ...event, elapsedMs: state.elapsedMs, score: state.scores.get(event.playerId) ?? 0 }; + context?.broadcast({ channel: 'gamemode', gamemodeId: valid.manifest.id, ...full }); + onEvent?.(full); + } + + return { + id: valid.manifest.id, + checkpoints: params.checkpoints, + init(nextContext) { + context = nextContext; + }, + start(state) { + const runtime = asRuntimeState(state); + runtime.startedAtMs = context?.now() ?? Date.now(); + for (const player of runtime.players.values()) ensureProgress(runtime, player.id); + }, + tick(_dt, state) { + const runtime = asRuntimeState(state); + if (runtime.ended) return; + for (const player of runtime.players.values()) { + ensureProgress(runtime, player.id); + if (params.type === 'parkour') maybeResetFallenPlayer(runtime, player, emit); + processCheckpoint(runtime, player, emit); + } + if (this.isWinConditionMet(runtime)) { + runtime.ended = true; + void this.end(runtime); + } + }, + end(state) { + const runtime = asRuntimeState(state); + runtime.results = [...runtime.progress.entries()] + .map(([playerId, progress]) => ({ + playerId, + score: runtime.scores.get(playerId) ?? 0, + completedAtMs: progress.finishedAtMs, + bestLapMs: progress.bestLapMs, + falls: progress.falls, + })) + .sort((a, b) => b.score - a.score || (a.completedAtMs ?? Number.MAX_SAFE_INTEGER) - (b.completedAtMs ?? Number.MAX_SAFE_INTEGER)); + }, + onPlayerJoin(player, state) { + const runtime = asRuntimeState(state); + runtime.players.set(player.id, player); + runtime.scores.set(player.id, 0); + runtime.progress.set(player.id, createInitialProgress(params)); + }, + onPlayerLeave(playerId, state) { + const runtime = asRuntimeState(state); + runtime.players.delete(playerId); + }, + score(playerId, state) { + return state.scores.get(playerId) ?? 0; + }, + isWinConditionMet(state) { + const runtime = asRuntimeState(state); + if (runtime.progress.size === 0) return false; + for (const progress of runtime.progress.values()) { + if (progress.finishedAtMs !== undefined) return true; + } + return params.timeLimitMs !== undefined && runtime.elapsedMs >= params.timeLimitMs; + }, + }; +} + +export function createInitialProgress(params: GamemodeParams): PlayerProgress { + return { + nextCheckpointIndex: 0, + completedCheckpoints: 0, + lap: 0, + lapStartedAtMs: 0, + lastCheckpointPosition: params.startPosition, + falls: 0, + }; +} + +export function orderedCheckpoints(params: GamemodeParams): CheckpointDefinition[] { + return [...params.checkpoints].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +export function processCheckpoint( + state: RuntimeGamemodeState, + player: PlayerSnapshot, + emit?: (state: RuntimeGamemodeState, event: Omit) => void, +): boolean { + const checkpoints = orderedCheckpoints(state.params); + const progress = ensureProgress(state, player.id); + if (progress.finishedAtMs !== undefined) return false; + const checkpoint = checkpoints[progress.nextCheckpointIndex]; + if (!checkpoint || !isInsideCheckpoint(player, checkpoint)) return false; + + progress.completedCheckpoints += 1; + progress.nextCheckpointIndex += 1; + progress.lastCheckpointId = checkpoint.id; + progress.lastCheckpointPosition = checkpoint.position; + state.scores.set(player.id, calculateScore(state.params, progress, state.elapsedMs)); + emit?.(state, { type: 'checkpoint', playerId: player.id, checkpointId: checkpoint.id }); + + if (progress.nextCheckpointIndex >= checkpoints.length) { + progress.nextCheckpointIndex = 0; + progress.lap += 1; + const lapMs = state.elapsedMs - progress.lapStartedAtMs; + progress.bestLapMs = Math.min(progress.bestLapMs ?? lapMs, lapMs); + progress.lapStartedAtMs = state.elapsedMs; + emit?.(state, { type: 'lap', playerId: player.id, checkpointId: checkpoint.id }); + const targetLaps = state.params.type === 'race' ? state.params.laps ?? 1 : 1; + if (progress.lap >= targetLaps) { + progress.finishedAtMs = state.elapsedMs; + state.scores.set(player.id, calculateScore(state.params, progress, state.elapsedMs)); + emit?.(state, { type: 'finish', playerId: player.id, checkpointId: checkpoint.id }); + } + } + return true; +} + +export function calculateScore(params: GamemodeParams, progress: PlayerProgress, elapsedMs: number): number { + const completionBonus = progress.finishedAtMs !== undefined ? 100_000 : 0; + const checkpointScore = progress.completedCheckpoints * 1_000; + const lapScore = progress.lap * 5_000; + const speedPenalty = Math.floor(elapsedMs / 100); + const fallPenalty = params.type === 'parkour' ? progress.falls * 500 : 0; + return Math.max(0, completionBonus + checkpointScore + lapScore - speedPenalty - fallPenalty); +} + +export function isInsideCheckpoint(player: PlayerSnapshot, checkpoint: CheckpointDefinition): boolean { + const dx = player.x - checkpoint.position.x; + const dy = player.y - checkpoint.position.y; + const dz = player.z - checkpoint.position.z; + return dx * dx + dy * dy + dz * dz <= checkpoint.radius * checkpoint.radius; +} + +export function maybeResetFallenPlayer( + state: RuntimeGamemodeState, + player: PlayerSnapshot, + emit?: (state: RuntimeGamemodeState, event: Omit) => void, +): boolean { + const fallY = state.params.fallY ?? -20; + if (player.y >= fallY) return false; + const progress = ensureProgress(state, player.id); + progress.falls += 1; + player.x = progress.lastCheckpointPosition.x; + player.y = progress.lastCheckpointPosition.y; + player.z = progress.lastCheckpointPosition.z; + state.scores.set(player.id, calculateScore(state.params, progress, state.elapsedMs)); + emit?.(state, { type: 'fall', playerId: player.id, checkpointId: progress.lastCheckpointId }); + return true; +} + +function ensureProgress(state: RuntimeGamemodeState, playerId: string): PlayerProgress { + let progress = state.progress.get(playerId); + if (!progress) { + progress = createInitialProgress(state.params); + state.progress.set(playerId, progress); + } + return progress; +} + +function asRuntimeState(state: GamemodeRuntimeState): RuntimeGamemodeState { + if (isRuntimeState(state)) return state; + throw new Error('gamemode state was not created by createRuntimeState'); +} + +function isRuntimeState(state: GamemodeRuntimeState): state is RuntimeGamemodeState { + return 'params' in state + && 'progress' in state + && state.progress instanceof Map + && 'results' in state + && Array.isArray(state.results) + && 'startedAtMs' in state + && typeof state.startedAtMs === 'number' + && 'ended' in state + && typeof state.ended === 'boolean'; +} diff --git a/src/net/client.ts b/src/net/client.ts index e8c8e73..a702780 100644 --- a/src/net/client.ts +++ b/src/net/client.ts @@ -1,3 +1,4 @@ +import type { RoomClientMessage, RoomServerMessage, RoomSummary } from '../contracts/roomProtocol.ts'; import type { ClientMessage, MultiplayerTransport, PlayerSnapshot, ServerMessage } from './protocol.ts'; import { CONNECT_TIMEOUT_MS, STATE_SEND_INTERVAL_MS } from './protocol.ts'; @@ -16,6 +17,9 @@ export interface MultiplayerClientOptions { onPlayerLeft?: (id: string) => void; onPlayerState?: (id: string, state: Omit) => void; onChat?: (id: string, name: string, text: string, ts: number) => void; + onRoomList?: (rooms: RoomSummary[]) => void; + onRoomState?: (roomId: string, gamemodeId: string, players: string[]) => void; + onGamemodeStart?: (roomId: string, gamemodeId: string, hostId: string) => void; } export class MultiplayerClient { @@ -115,6 +119,26 @@ export class MultiplayerClient { this.sendWs({ type: 'state', state }); } + listRooms() { + this.sendRoomWs({ type: 'list_rooms' }); + } + + createRoom(roomId: string, gamemodeId: string, playerId = this.localId || this.options.name) { + this.sendRoomWs({ type: 'create_room', roomId, gamemodeId, playerId }); + } + + joinLobbyRoom(roomId: string, playerId = this.localId || this.options.name) { + this.sendRoomWs({ type: 'join_room', roomId, playerId }); + } + + leaveLobbyRoom(roomId: string, playerId = this.localId || this.options.name) { + this.sendRoomWs({ type: 'leave_room', roomId, playerId }); + } + + startGamemode(roomId = this.options.room, playerId = this.localId || this.options.name) { + this.sendRoomWs({ type: 'start_gamemode', roomId, playerId }); + } + sendChat(text: string) { const trimmed = text.trim().slice(0, 200); if (!trimmed || !this.isConnected) return; @@ -258,14 +282,20 @@ export class MultiplayerClient { } } + private sendRoomWs(msg: RoomClientMessage) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ channel: 'room', ...msg })); + } + } + private setStatus(status: MultiplayerStatus, detail?: string) { this.options.onStatus?.(status, detail); } private handleMessage(raw: unknown) { - let msg: ServerMessage; + let msg: ServerMessage | RoomServerMessage; try { - msg = JSON.parse(String(raw)) as ServerMessage; + msg = JSON.parse(String(raw)) as ServerMessage | RoomServerMessage; } catch { return; } @@ -290,6 +320,15 @@ export class MultiplayerClient { case 'chat': this.options.onChat?.(msg.id, msg.name, msg.text, msg.ts); break; + case 'room_list': + this.options.onRoomList?.(msg.rooms); + break; + case 'room_state': + this.options.onRoomState?.(msg.roomId, msg.gamemodeId, msg.players); + break; + case 'start_gamemode': + this.options.onGamemodeStart?.(msg.roomId, msg.gamemodeId, msg.hostId); + break; } } } diff --git a/src/net/protocol.test.ts b/src/net/protocol.test.ts new file mode 100644 index 0000000..8b1f51d --- /dev/null +++ b/src/net/protocol.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import type { PlayerSnapshot } from './protocol.ts'; + +describe('PlayerSnapshot cosmetics compatibility', () => { + test('accepts old snapshots without cosmetics and new snapshots with cosmetics', () => { + const oldSnapshot: PlayerSnapshot = { + id: 'old-client', + name: 'Old Pup', + color: 0xffb703, + x: 1, + y: 2, + z: 3, + qx: 0, + qy: 0, + qz: 0, + qw: 1, + heading: 0, + speed: 0, + isGrounded: true, + boardTiltX: 0, + boardTiltZ: 0, + }; + const newSnapshot: PlayerSnapshot = { ...oldSnapshot, id: 'new-client', cosmetics: { board: 'board-id', trail: 'trail-id' } }; + + expect(oldSnapshot.cosmetics).toBeUndefined(); + expect(newSnapshot.cosmetics?.trail).toBe('trail-id'); + }); +}); diff --git a/src/net/protocol.ts b/src/net/protocol.ts index 679aa6d..7c9644c 100644 --- a/src/net/protocol.ts +++ b/src/net/protocol.ts @@ -1,3 +1,5 @@ +import type { EquippedCosmetics } from '../cosmetics/registry.ts'; + export const DEFAULT_WS_PORT = 3001; export const DEFAULT_ROOM = 'lunar-park'; export const STATE_SEND_INTERVAL_MS = 50; @@ -30,6 +32,7 @@ export interface PlayerSnapshot { isGrounded: boolean; boardTiltX: number; boardTiltZ: number; + cosmetics?: EquippedCosmetics; } export type MultiplayerTransport = 'ws' | 'http'; @@ -83,6 +86,24 @@ export function getWsUrl(port = DEFAULT_WS_PORT): string | null { return null; } +/** + * HTTP base URL for the game API server (rooms, cosmetics, lootbox, agent events). + * Derived from the WebSocket server host (so `?ws=` overrides both), mapped to + * http(s). Empty string means same-origin — for deployments that proxy the API. + */ +export function getApiBaseUrl(port = DEFAULT_WS_PORT): string { + const ws = getWsUrl(port); + if (ws) { + const url = new URL(ws); + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; + url.pathname = ''; + url.search = ''; + return url.href.replace(/\/+$/, ''); + } + + return ''; +} + export function getMultiplayerTransport(): MultiplayerTransport { const params = new URLSearchParams(location.search); if (params.get('ws')?.trim()) return 'ws'; diff --git a/src/server.ts b/src/server.ts index 41f5182..5be5757 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,174 +1,58 @@ -import type { ServerWebSocket } from 'bun'; -import { - DEFAULT_ROOM, - DEFAULT_WS_PORT, - PLAYER_COLORS, - parseClientMessage, - type ClientMessage, - type PlayerSnapshot, - type ServerMessage, -} from './net/protocol.ts'; - -interface PlayerConnection { - id: string; - name: string; - color: number; - room: string; - ws?: ServerWebSocket; - state: Omit; -} - -interface Room { - players: Map; - usedColors: Set; -} - -const rooms = new Map(); - -function getRoom(roomId: string): Room { - let room = rooms.get(roomId); - if (!room) { - room = { players: new Map(), usedColors: new Set() }; - rooms.set(roomId, room); - } - return room; -} - -function pickColor(room: Room): number { - for (const color of PLAYER_COLORS) { - if (!room.usedColors.has(color)) return color; - } - return PLAYER_COLORS[Math.floor(Math.random() * PLAYER_COLORS.length)]!; -} - -function snapshotFrom(conn: PlayerConnection): PlayerSnapshot { - return { id: conn.id, name: conn.name, color: conn.color, ...conn.state }; +import { DEFAULT_WS_PORT } from './net/protocol.ts'; +import { createInitialConnection, registerMultiplayerModule, removePlayer, type PlayerConnection } from './server/multiplayer.ts'; +import { registerAgentEventsModule } from './server/agentEvents.ts'; +import { registerRoomsModule } from './server/rooms.ts'; +import { registerCosmeticsModule } from './server/cosmetics.ts'; +import { registerGamemodeModule } from './server/gamemodes.ts'; +import { ModularRouter } from './server/router.ts'; +import { registerWalletModule } from './server/wallet.ts'; + +export function createServerRouter(): ModularRouter { + const router = new ModularRouter(); + registerRoomsModule(router); + registerMultiplayerModule(router); + registerAgentEventsModule(router); + registerGamemodeModule(router); + registerWalletModule(router); + registerCosmeticsModule(router); + return router; } -function defaultState(): Omit { - return { - x: 0, y: 0, z: 0, - qx: 0, qy: 0, qz: 0, qw: 1, - heading: 0, speed: 0, isGrounded: true, - boardTiltX: 0, boardTiltZ: 0, - }; -} - -function pendingConnection(): PlayerConnection { - return { - id: '', - name: '', - color: PLAYER_COLORS[0], - room: '', - state: defaultState(), - }; -} - -function send(ws: ServerWebSocket, msg: ServerMessage) { - ws.send(JSON.stringify(msg)); -} - -function broadcast(room: Room, msg: ServerMessage, exceptId?: string) { - for (const [id, conn] of room.players) { - if (id !== exceptId && conn.ws) send(conn.ws, msg); - } -} - -function removePlayer(conn: PlayerConnection) { - const room = rooms.get(conn.room); - if (!room) return; - - room.players.delete(conn.id); - room.usedColors.delete(conn.color); - broadcast(room, { type: 'player_left', id: conn.id }); - - if (room.players.size === 0) { - rooms.delete(conn.room); - } -} - -function handleJoin(ws: ServerWebSocket, msg: Extract) { - const roomId = msg.room.trim() || DEFAULT_ROOM; - const room = getRoom(roomId); - const id = crypto.randomUUID(); - const color = pickColor(room); - const name = msg.name.trim().slice(0, 24) || `Pup${Math.floor(Math.random() * 900 + 100)}`; - - const conn: PlayerConnection = { - id, - name, - color, - room: roomId, - ws, - state: defaultState(), - }; - - ws.data = conn; - room.players.set(id, conn); - room.usedColors.add(color); - - const existing = [...room.players.values()] - .filter(p => p.id !== id) - .map(snapshotFrom); - - send(ws, { type: 'welcome', id, color, room: roomId, players: existing }); - broadcast(room, { type: 'player_joined', player: snapshotFrom(conn) }, id); - - console.log(`[+] ${name} joined room "${roomId}" (${room.players.size} players)`); -} - -function handleState(conn: PlayerConnection, msg: Extract) { - conn.state = msg.state; - const room = rooms.get(conn.room); - if (!room) return; - broadcast(room, { type: 'state', id: conn.id, state: msg.state }, conn.id); -} +const router = createServerRouter(); +const port = Number(process.env.PORT) || DEFAULT_WS_PORT; -function handleChat(conn: PlayerConnection, msg: Extract) { - const text = msg.text.trim().slice(0, 200); - if (!text) return; - const room = rooms.get(conn.room); - if (!room) return; - broadcast(room, { - type: 'chat', - id: conn.id, - name: conn.name, - text, - ts: Date.now(), - }); +// The game page is served from a different origin than this API server +// (dev: :3000 vs :3001; prod: Netlify vs the game server), so every HTTP +// response needs CORS. Auth is token-based, never cookie-based, so '*' is safe. +const CORS_HEADERS: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +function withCors(res: Response): Response { + for (const [key, value] of Object.entries(CORS_HEADERS)) res.headers.set(key, value); + return res; } -const port = Number(process.env.PORT) || DEFAULT_WS_PORT; - const server = Bun.serve({ port, - fetch(req, server) { - if (server.upgrade(req, { data: pendingConnection() })) return undefined; - return new Response('Lunar Pup multiplayer WebSocket server', { - headers: { 'Content-Type': 'text/plain' }, + async fetch(req, server) { + if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS_HEADERS }); + const handled = await router.handleHttp(req, { + server, + upgrade: (data) => server.upgrade(req, { data: data ?? (undefined as unknown as PlayerConnection) }), }); + if (handled) return withCors(handled); + if (server.upgrade(req, { data: undefined as unknown as PlayerConnection })) return undefined; + return withCors(new Response('Not found', { status: 404 })); }, websocket: { open(ws) { - ws.data.ws = ws; + ws.data = createInitialConnection(ws); }, message(ws, message) { - const parsed = parseClientMessage(message); - if (!parsed) return; - - if (parsed.type === 'join') { - if (ws.data.id) return; - handleJoin(ws, parsed); - return; - } - - if (!ws.data.id) return; - - if (parsed.type === 'state') { - handleState(ws.data, parsed); - } else if (parsed.type === 'chat') { - handleChat(ws.data, parsed); - } + router.dispatchWebSocket(ws, message); }, close(ws) { if (!ws.data.id) return; diff --git a/src/server/agentEvents.test.ts b/src/server/agentEvents.test.ts new file mode 100644 index 0000000..12be5c3 --- /dev/null +++ b/src/server/agentEvents.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'bun:test'; +import type { Server, ServerWebSocket } from 'bun'; +import type { AgentEvent } from '../contracts/agentEvents.ts'; +import type { EventLedgerStorage, LedgerEvent, LedgerQuery } from '../contracts/services.ts'; +import { registerAgentEventsModule } from './agentEvents.ts'; +import type { PlayerConnection } from './multiplayer.ts'; +import { ModularRouter } from './router.ts'; + +class MemoryLedger implements EventLedgerStorage { + readonly events: LedgerEvent[] = []; + + append(event: Omit, 'id'>): LedgerEvent { + const saved = { id: this.events.length + 1, ...event } as LedgerEvent; + this.events.push(saved as LedgerEvent); + return saved; + } + + query(_query: LedgerQuery): LedgerEvent[] { + return this.events; + } +} + +function validEvent(overrides: Partial = {}): AgentEvent { + return { + type: 'agent_status', + harness: 'claude-code', + sessionId: 'session-1', + project: 'lunarpup', + message: 'running tests', + timestamp: '2026-07-09T12:00:00.000Z', + ...overrides, + }; +} + +function request(body: unknown, token = 'secret'): Request { + return new Request('http://localhost/agent/event', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + +describe('agent events module', () => { + test('validates auth and event body before accepting endpoint posts', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + const published: string[] = []; + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: (_topic: string, payload: string) => published.push(payload) }; + const typedServer = server as unknown as Server; + + const unauthorized = await router.handleHttp(request(validEvent(), 'wrong'), { server: typedServer, upgrade: () => false }); + expect(unauthorized?.status).toBe(401); + + const invalid = await router.handleHttp(request({ ...validEvent(), type: 'bad' }), { server: typedServer, upgrade: () => false }); + expect(invalid?.status).toBe(400); + + const accepted = await router.handleHttp(request(validEvent()), { server: typedServer, upgrade: () => false }); + expect(accepted?.status).toBe(202); + expect(ledger.events).toHaveLength(1); + expect(published).toHaveLength(1); + }); + + test('appends accepted events to the ledger with session entity id', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: () => 1 }; + const typedServer = server as unknown as Server; + const event = validEvent({ type: 'agent_needs_input', sessionId: 'session-42', message: 'Approve deploy?' }); + + await router.handleHttp(request(event), { server: typedServer, upgrade: () => false }); + + expect(ledger.events).toHaveLength(1); + expect(ledger.events[0]).toMatchObject({ + type: 'agent_needs_input', + entityId: 'session-42', + timestamp: event.timestamp, + payload: event, + }); + }); + + test('subscribes websocket clients and broadcasts accepted events on agent-events channel', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + const broadcasts: Array<{ topic: string; payload: string }> = []; + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: (topic: string, payload: string) => broadcasts.push({ topic, payload }) }; + const typedServer = server as unknown as Server; + const subscribed: string[] = []; + const sent: string[] = []; + const ws = { + subscribe: (topic: string) => subscribed.push(topic), + send: (payload: string) => sent.push(payload), + }; + const typedWs = ws as unknown as ServerWebSocket; + + expect(router.dispatchWebSocket(typedWs, { channel: 'agent-events', type: 'subscribe' })).toBe(true); + await router.handleHttp(request(validEvent({ type: 'agent_done' })), { server: typedServer, upgrade: () => false }); + + expect(subscribed).toEqual(['agent-events']); + expect(JSON.parse(sent[0] ?? '{}')).toEqual({ channel: 'agent-events', type: 'subscribed' }); + expect(broadcasts).toHaveLength(1); + expect(broadcasts[0]?.topic).toBe('agent-events'); + expect(JSON.parse(broadcasts[0]?.payload ?? '{}')).toEqual({ channel: 'agent-events', event: validEvent({ type: 'agent_done' }) }); + }); +}); diff --git a/src/server/agentEvents.ts b/src/server/agentEvents.ts new file mode 100644 index 0000000..247a75a --- /dev/null +++ b/src/server/agentEvents.ts @@ -0,0 +1,63 @@ +import type { Server, ServerWebSocket } from 'bun'; +import { validateAgentEvent, type AgentEvent } from '../contracts/agentEvents.ts'; +import { SqliteEventLedgerStorage, type EventLedgerStorage } from '../contracts/services.ts'; +import type { PlayerConnection } from './multiplayer.ts'; +import type { ModularRouter } from './router.ts'; + +const AGENT_EVENTS_TOPIC = 'agent-events'; + +export interface AgentEventsModuleOptions { + ledger?: EventLedgerStorage; + token?: string; +} + +export interface AgentEventBroadcast { + channel: 'agent-events'; + event: AgentEvent; +} + + +async function parseJson(request: Request): Promise { + try { + return await request.json(); + } catch { + return undefined; + } +} + +async function appendAndBroadcast(server: Server, ledger: EventLedgerStorage, event: AgentEvent): Promise { + await ledger.append({ + type: event.type, + entityId: event.sessionId, + timestamp: event.timestamp, + payload: event, + }); + server.publish(AGENT_EVENTS_TOPIC, JSON.stringify({ channel: AGENT_EVENTS_TOPIC, event } satisfies AgentEventBroadcast)); +} + +export function registerAgentEventsModule(router: ModularRouter, options: AgentEventsModuleOptions = {}): void { + const ledger = options.ledger ?? new SqliteEventLedgerStorage(); + + router.registerHttp('POST', '/agent/event', async (request, context) => { + const token = options.token ?? process.env.AGENT_EVENT_TOKEN; + const requestBearer = request.headers.get('authorization'); + const [scheme, bearer] = requestBearer?.split(/\s+/, 2) ?? []; + const requestSecret = scheme?.toLowerCase() === 'bearer' && bearer ? bearer : request.headers.get('x-agent-event-token'); + if (!token || requestSecret !== token) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } }); + } + + const parsed = validateAgentEvent(await parseJson(request)); + if (!parsed.ok) { + return new Response(JSON.stringify({ error: parsed.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + + await appendAndBroadcast(context.server, ledger, parsed.value); + return new Response(JSON.stringify({ ok: true }), { status: 202, headers: { 'Content-Type': 'application/json' } }); + }); + + router.registerWebSocket(AGENT_EVENTS_TOPIC, (ws: ServerWebSocket) => { + ws.subscribe(AGENT_EVENTS_TOPIC); + ws.send(JSON.stringify({ channel: AGENT_EVENTS_TOPIC, type: 'subscribed' })); + }); +} diff --git a/src/server/cosmetics.test.ts b/src/server/cosmetics.test.ts new file mode 100644 index 0000000..66594a6 --- /dev/null +++ b/src/server/cosmetics.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { packageManifestId, type PackageManifest } from '../contracts/packageManifest.ts'; +import { loadCosmeticPackage } from '../cosmetics/registry.ts'; +import { createCosmeticsServices, equippedFor, registerCosmeticsModule } from './cosmetics.ts'; +import { ModularRouter } from './router.ts'; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const path of cleanupPaths.splice(0)) { + if (existsSync(path)) rmSync(path, { recursive: true, force: true }); + } +}); + +describe('cosmetic package loader', () => { + test('rejects manifest content-address tampering', async () => { + const dir = mkdtempSync(join(tmpdir(), 'lunarpup-cosmetic-')); + cleanupPaths.push(dir); + const definition = '{"id":"tamper-board","rarity":"common","slot":"board","visual":{"colors":["#ffffff"]}}'; + const manifestWithoutId: Omit = { + kind: 'cosmetic', + version: '1.0.0', + author: 'Test', + displayName: 'Tamper Board', + assetRefs: [{ name: 'definition', uri: 'definition.json', sha256: await sha256(definition), mediaType: 'application/json' }], + metadata: { price: '10' }, + }; + const manifest = { id: packageManifestId(manifestWithoutId), ...manifestWithoutId }; + writeFileSync(join(dir, 'definition.json'), definition); + writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ ...manifest, displayName: 'Changed Name' })); + + await expect(loadCosmeticPackage(dir)).rejects.toThrow('id must be sha256 of canonical manifest JSON'); + }); +}); + +describe('cosmetics server module', () => { + test('buy, equip, and inventory round-trip through sqlite services', async () => { + const dbPath = join(tmpdir(), `lunarpup-cosmetics-${crypto.randomUUID()}.db`); + cleanupPaths.push(dbPath, `${dbPath}-shm`, `${dbPath}-wal`); + const services = createCosmeticsServices(dbPath); + services.currency.grant('acct-1', 1000, 'test funds'); + const router = new ModularRouter(); + registerCosmeticsModule(router, services); + + const catalogResponse = await route(router, new Request('http://localhost/api/cosmetics/catalog')); + expect(catalogResponse.status).toBe(200); + const catalogPayload = await catalogResponse.json() as { catalog: Array<{ id: string; definition: { slot: string } }> }; + const board = catalogPayload.catalog.find(item => item.definition.slot === 'board'); + expect(board).toBeDefined(); + + const buyResponse = await route(router, jsonRequest('http://localhost/api/cosmetics/buy', { accountId: 'acct-1', cosmeticId: board!.id })); + expect(buyResponse.status).toBe(200); + expect((await buyResponse.json() as { ownedIds: string[] }).ownedIds).toContain(board!.id); + + const equipResponse = await route(router, jsonRequest('http://localhost/api/cosmetics/equip', { accountId: 'acct-1', cosmeticId: board!.id, slot: 'board' })); + expect(equipResponse.status).toBe(200); + const equipPayload = await equipResponse.json() as { equipped: { board?: string }; ownedIds: string[] }; + expect(equipPayload.equipped.board).toBe(board!.id); + expect(equipPayload.ownedIds).toContain(board!.id); + expect(await equippedFor('acct-1', services.ledger)).toEqual({ board: board!.id }); + }); +}); + +async function route(router: ModularRouter, request: Request): Promise { + const response = await router.handleHttp(request, { server: {} as never, upgrade: () => false }); + if (!response) throw new Error('route was not handled'); + return response; +} + +function jsonRequest(url: string, body: Record): Request { + return new Request(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); +} + +async function sha256(source: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source)); + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/server/cosmetics.ts b/src/server/cosmetics.ts new file mode 100644 index 0000000..d3ff448 --- /dev/null +++ b/src/server/cosmetics.ts @@ -0,0 +1,118 @@ +import { InsufficientFundsError, SqliteCurrencyInventoryService, SqliteEventLedgerStorage, type CurrencyInventoryService, type EventLedgerStorage } from '../contracts/services.ts'; +import { equippedWith, getCosmeticById, loadCosmeticCatalog, sanitizeEquippedCosmetics, type EquippedCosmetics } from '../cosmetics/registry.ts'; +import type { CosmeticSlot } from '../contracts/cosmetic.ts'; +import type { ModularRouter } from './router.ts'; + +interface CosmeticsServices { + currency: CurrencyInventoryService; + ledger: EventLedgerStorage; +} + +export function createCosmeticsServices(path?: string): CosmeticsServices { + const ledger = new SqliteEventLedgerStorage(path ? { path } : {}); + const currency = new SqliteCurrencyInventoryService(path ? { path, ledger } : { ledger }); + return { currency, ledger }; +} + +export function registerCosmeticsModule(router: ModularRouter, services = createCosmeticsServices()): void { + router.registerHttp('GET', '/api/cosmetics/catalog', async () => jsonResponse({ catalog: await publicCatalog() })); + + router.registerHttp('GET', '/api/cosmetics/inventory', async (request) => { + const accountId = accountFromRequest(request); + if (!accountId) return jsonResponse({ error: 'accountId is required' }, 400); + return jsonResponse(await inventoryPayload(accountId, services)); + }); + + router.registerHttp('POST', '/api/cosmetics/buy', async (request) => { + const body = await readJsonBody(request); + const accountId = typeof body.accountId === 'string' ? body.accountId : ''; + const cosmeticId = typeof body.cosmeticId === 'string' ? body.cosmeticId : ''; + if (!accountId || !cosmeticId) return jsonResponse({ error: 'accountId and cosmeticId are required' }, 400); + + const cosmetic = await getCosmeticById(cosmeticId); + if (!cosmetic) return jsonResponse({ error: 'unknown cosmetic' }, 404); + + const owned = new Set(await services.currency.listOwnedItems(accountId)); + if (!owned.has(cosmetic.id)) { + try { + if (cosmetic.price > 0) services.currency.spend(accountId, cosmetic.price, `buy:${cosmetic.id}`); + services.currency.grantOwnedItem(accountId, cosmetic.id, 'cosmetic purchase'); + } catch (error) { + if (error instanceof InsufficientFundsError) { + return jsonResponse({ error: 'insufficient_funds', balance: error.balance, required: error.required }, 402); + } + throw error; + } + } + + return jsonResponse(await inventoryPayload(accountId, services)); + }); + + router.registerHttp('POST', '/api/cosmetics/equip', async (request) => { + const body = await readJsonBody(request); + const accountId = typeof body.accountId === 'string' ? body.accountId : ''; + const cosmeticId = body.cosmeticId === null ? null : typeof body.cosmeticId === 'string' ? body.cosmeticId : ''; + const slot = typeof body.slot === 'string' ? body.slot : ''; + if (!accountId || !isCosmeticSlot(slot) || cosmeticId === '') return jsonResponse({ error: 'accountId, slot, and cosmeticId are required' }, 400); + + let next = await equippedFor(accountId, services.ledger); + if (cosmeticId) { + const cosmetic = await getCosmeticById(cosmeticId); + if (!cosmetic) return jsonResponse({ error: 'unknown cosmetic' }, 404); + if (cosmetic.definition.slot !== slot) return jsonResponse({ error: 'cosmetic does not fit slot' }, 400); + const owned = new Set(await services.currency.listOwnedItems(accountId)); + if (!owned.has(cosmetic.id)) return jsonResponse({ error: 'cosmetic not owned' }, 403); + } + + next = equippedWith(slot, cosmeticId, next); + services.ledger.append({ type: 'cosmetics_equipped', entityId: accountId, timestamp: new Date().toISOString(), payload: next }); + return jsonResponse(await inventoryPayload(accountId, services)); + }); +} + +export async function equippedFor(accountId: string, ledger: EventLedgerStorage): Promise { + const events = await ledger.query({ type: 'cosmetics_equipped', entityId: accountId }); + const last = events.at(-1); + return sanitizeEquippedCosmetics(last?.payload); +} + +async function publicCatalog() { + return (await loadCosmeticCatalog()).map(pkg => ({ + id: pkg.id, + manifest: pkg.manifest, + definition: pkg.definition, + price: pkg.price, + })); +} + +async function inventoryPayload(accountId: string, services: CosmeticsServices) { + const [catalog, ownedIds, balance, equipped] = await Promise.all([ + publicCatalog(), + services.currency.listOwnedItems(accountId), + services.currency.getBalance(accountId), + equippedFor(accountId, services.ledger), + ]); + return { accountId, balance, ownedIds, equipped, catalog }; +} + +function accountFromRequest(request: Request): string { + const url = new URL(request.url); + return url.searchParams.get('accountId') ?? ''; +} + +async function readJsonBody(request: Request): Promise> { + try { + const value = await request.json(); + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } catch { + return {}; + } +} + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json; charset=utf-8' } }); +} + +function isCosmeticSlot(value: string): value is CosmeticSlot { + return value === 'board' || value === 'body' || value === 'trail' || value === 'aura'; +} diff --git a/src/server/gamemodes.ts b/src/server/gamemodes.ts new file mode 100644 index 0000000..c30a74b --- /dev/null +++ b/src/server/gamemodes.ts @@ -0,0 +1,90 @@ +import { SqliteEventLedgerStorage, type EventLedgerStorage } from '../contracts/services.ts'; +import type { ModularRouter } from './router.ts'; +import type { PlayerConnection } from './multiplayer.ts'; + +interface RunSamplePoint { + t: number; + x: number; + y: number; + z: number; + speed: number; +} + +interface RunSampleMessage { + channel: 'gamemode'; + type: 'run_sample'; + gamemodeId: string; + playerId: string; + reason: 'sample' | 'finish' | 'abandon'; + samples: RunSamplePoint[]; +} + +export interface GamemodeServerOptions { + ledger?: EventLedgerStorage; +} + +export function registerGamemodeModule(router: ModularRouter, options: GamemodeServerOptions = {}): void { + const ledger = options.ledger ?? new SqliteEventLedgerStorage(); + router.registerWebSocket('gamemode', (ws, payload) => { + const message = parseRunSampleMessage(payload); + if (!message) { + ws.send(JSON.stringify({ channel: 'gamemode', type: 'error', message: 'invalid gamemode payload' })); + return; + } + const entityId = `${message.gamemodeId}:${message.playerId}`; + void ledger.append({ + type: 'gamemode_run_sample', + entityId, + timestamp: new Date().toISOString(), + payload: { + gamemodeId: message.gamemodeId, + playerId: message.playerId, + reason: message.reason, + samples: message.samples, + }, + }); + ws.send(JSON.stringify({ channel: 'gamemode', type: 'sample_ack', count: message.samples.length })); + }); +} + +export function parseRunSampleMessage(payload: unknown): RunSampleMessage | null { + const data = decodePayload(payload); + if (!data || data.channel !== 'gamemode' || data.type !== 'run_sample') return null; + if (typeof data.gamemodeId !== 'string' || data.gamemodeId.length === 0) return null; + if (typeof data.playerId !== 'string' || data.playerId.length === 0) return null; + if (data.reason !== 'sample' && data.reason !== 'finish' && data.reason !== 'abandon') return null; + if (!Array.isArray(data.samples) || data.samples.length === 0) return null; + const samples: RunSamplePoint[] = []; + for (const sample of data.samples) { + if (!sample || typeof sample !== 'object') return null; + if (!('t' in sample) || !('x' in sample) || !('y' in sample) || !('z' in sample) || !('speed' in sample)) return null; + const point = { t: sample.t, x: sample.x, y: sample.y, z: sample.z, speed: sample.speed }; + if (!Number.isFinite(point.t) || !Number.isFinite(point.x) || !Number.isFinite(point.y) || !Number.isFinite(point.z) || !Number.isFinite(point.speed)) return null; + samples.push(point); + } + return { channel: 'gamemode', type: 'run_sample', gamemodeId: data.gamemodeId, playerId: data.playerId, reason: data.reason, samples }; +} + +function decodePayload(payload: unknown): Record | null { + if (typeof payload === 'string') { + try { + const parsed = JSON.parse(payload) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + } + if (payload instanceof Buffer) { + try { + const parsed = JSON.parse(payload.toString()) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + } + return isRecord(payload) ? payload : null; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} diff --git a/src/server/multiplayer.ts b/src/server/multiplayer.ts new file mode 100644 index 0000000..c1b2129 --- /dev/null +++ b/src/server/multiplayer.ts @@ -0,0 +1,148 @@ +import type { ServerWebSocket } from 'bun'; +import { + DEFAULT_ROOM, + PLAYER_COLORS, + parseClientMessage, + type ClientMessage, + type PlayerSnapshot, + type ServerMessage, +} from '../net/protocol.ts'; +import type { ModularRouter } from './router.ts'; + +export interface PlayerConnection { + id: string; + name: string; + color: number; + room: string; + ws: ServerWebSocket; + state: Omit; +} + +export interface Room { + players: Map; + usedColors: Set; + gamemodeId: string; +} + +const rooms = new Map(); + +export function ensureRoom(roomId: string, gamemodeId = 'free-skate'): Room { + let room = rooms.get(roomId); + if (!room) { + room = { players: new Map(), usedColors: new Set(), gamemodeId }; + rooms.set(roomId, room); + } else if (gamemodeId) { + room.gamemodeId = gamemodeId; + } + return room; +} + +function pickColor(room: Room): number { + for (const color of PLAYER_COLORS) { + if (!room.usedColors.has(color)) return color; + } + return PLAYER_COLORS[Math.floor(Math.random() * PLAYER_COLORS.length)] ?? PLAYER_COLORS[0]; +} + +function snapshotFrom(conn: PlayerConnection): PlayerSnapshot { + return { id: conn.id, name: conn.name, color: conn.color, ...conn.state }; +} + +export function defaultPlayerState(): Omit { + return { + x: 0, y: 0, z: 0, + qx: 0, qy: 0, qz: 0, qw: 1, + heading: 0, speed: 0, isGrounded: true, + boardTiltX: 0, boardTiltZ: 0, + }; +} + +function send(ws: ServerWebSocket, msg: ServerMessage): void { + ws.send(JSON.stringify(msg)); +} + +function broadcast(room: Room, msg: ServerMessage, exceptId?: string): void { + for (const [id, conn] of room.players) { + if (id !== exceptId) send(conn.ws, msg); + } +} + +export function createInitialConnection(ws: ServerWebSocket): PlayerConnection { + return { + id: '', + name: '', + color: PLAYER_COLORS[0], + room: '', + ws, + state: defaultPlayerState(), + }; +} + +export function removePlayer(conn: PlayerConnection): void { + const room = rooms.get(conn.room); + if (!room) return; + room.players.delete(conn.id); + room.usedColors.delete(conn.color); + broadcast(room, { type: 'player_left', id: conn.id }); + if (room.players.size === 0) rooms.delete(conn.room); +} + +function handleJoin(ws: ServerWebSocket, msg: Extract): void { + const roomId = msg.room.trim() || DEFAULT_ROOM; + const room = ensureRoom(roomId); + const id = crypto.randomUUID(); + const color = pickColor(room); + const name = msg.name.trim().slice(0, 24) || `Pup${Math.floor(Math.random() * 900 + 100)}`; + const conn: PlayerConnection = { id, name, color, room: roomId, ws, state: defaultPlayerState() }; + ws.data = conn; + room.players.set(id, conn); + room.usedColors.add(color); + const existing = [...room.players.values()].filter(player => player.id !== id).map(snapshotFrom); + send(ws, { type: 'welcome', id, color, room: roomId, players: existing }); + broadcast(room, { type: 'player_joined', player: snapshotFrom(conn) }, id); + console.log(`[+] ${name} joined room "${roomId}" (${room.players.size} players, gamemode ${room.gamemodeId})`); +} + +function handleState(conn: PlayerConnection, msg: Extract): void { + conn.state = msg.state; + const room = rooms.get(conn.room); + if (!room) return; + broadcast(room, { type: 'state', id: conn.id, state: msg.state }, conn.id); +} + +function handleChat(conn: PlayerConnection, msg: Extract): void { + const text = msg.text.trim().slice(0, 200); + if (!text) return; + const room = rooms.get(conn.room); + if (!room) return; + broadcast(room, { + type: 'chat', + id: conn.id, + name: conn.name, + text, + ts: Date.now(), + }); +} + +export function registerMultiplayerModule(router: ModularRouter): void { + router.registerHttp('GET', '/', () => new Response('Lunar Pup multiplayer WebSocket server', { + headers: { 'Content-Type': 'text/plain' }, + })); + + router.registerWebSocket('multiplayer', (ws, payload) => { + const parsed = typeof payload === 'string' || payload instanceof Buffer ? parseClientMessage(payload) : parseClientMessage(JSON.stringify(payload)); + if (!parsed) return; + if (parsed.type === 'join') { + if (ws.data.id) return; + handleJoin(ws, parsed); + return; + } + if (!ws.data.id) return; + if (parsed.type === 'state') handleState(ws.data, parsed); + else if (parsed.type === 'chat') handleChat(ws.data, parsed); + }); +} + +export function getRoomSummaries(): Array<{ roomId: string; gamemodeId: string; playerCount: number }> { + return [...rooms.entries()].map(([roomId, room]) => ({ roomId, gamemodeId: room.gamemodeId, playerCount: room.players.size })); +} diff --git a/src/server/rooms.test.ts b/src/server/rooms.test.ts new file mode 100644 index 0000000..8e3173f --- /dev/null +++ b/src/server/rooms.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { ServerWebSocket } from 'bun'; +import type { RoomServerMessage } from '../contracts/roomProtocol.ts'; +import { createInitialConnection, type PlayerConnection } from './multiplayer.ts'; +import { registerRoomsModule, resetRoomsForTests } from './rooms.ts'; +import { ModularRouter } from './router.ts'; + +function makeWs() { + const messages: RoomServerMessage[] = []; + const ws = { + data: undefined as unknown as PlayerConnection, + send(raw: string) { + messages.push(JSON.parse(raw) as RoomServerMessage); + }, + } as ServerWebSocket; + ws.data = createInitialConnection(ws); + return { ws, messages }; +} + +function sendRoom(router: ModularRouter, ws: ServerWebSocket, payload: Record) { + expect(router.dispatchWebSocket(ws, { channel: 'room', ...payload })).toBe(true); +} + +function latestState(messages: RoomServerMessage[]) { + const states = messages.filter((message): message is Extract => message.type === 'room_state'); + return states.at(-1); +} + +describe('rooms module', () => { + beforeEach(() => resetRoomsForTests()); + + test('create/list/join round-trip', async () => { + const router = new ModularRouter(); + registerRoomsModule(router); + const host = makeWs(); + const guest = makeWs(); + + sendRoom(router, host.ws, { type: 'create_room', roomId: 'moon', gamemodeId: 'trick-attack', playerId: 'host' }); + const listed = await router.handleHttp(new Request('http://localhost/rooms'), { server: {} as never, upgrade: () => false }); + expect(listed?.status).toBe(200); + expect(await listed?.json()).toEqual({ rooms: [{ roomId: 'moon', gamemodeId: 'trick-attack', playerCount: 1 }] }); + + sendRoom(router, guest.ws, { type: 'join_room', roomId: 'moon', playerId: 'guest' }); + expect(latestState(host.messages)).toEqual({ type: 'room_state', roomId: 'moon', gamemodeId: 'trick-attack', players: ['host', 'guest'] }); + expect(latestState(guest.messages)).toEqual({ type: 'room_state', roomId: 'moon', gamemodeId: 'trick-attack', players: ['host', 'guest'] }); + }); + + test('simulated room members never receive another room broadcast', () => { + const router = new ModularRouter(); + registerRoomsModule(router); + const a1 = makeWs(); + const a2 = makeWs(); + const b1 = makeWs(); + + sendRoom(router, a1.ws, { type: 'create_room', roomId: 'a', gamemodeId: 'free-skate', playerId: 'a1' }); + sendRoom(router, b1.ws, { type: 'create_room', roomId: 'b', gamemodeId: 'checkpoint-race', playerId: 'b1' }); + b1.messages.length = 0; + + sendRoom(router, a2.ws, { type: 'join_room', roomId: 'a', playerId: 'a2' }); + + expect(latestState(a1.messages)?.players).toEqual(['a1', 'a2']); + expect(latestState(a2.messages)?.players).toEqual(['a1', 'a2']); + expect(b1.messages).toEqual([]); + }); + + test('host start relays to room members only', () => { + const router = new ModularRouter(); + registerRoomsModule(router); + const host = makeWs(); + const guest = makeWs(); + const other = makeWs(); + + sendRoom(router, host.ws, { type: 'create_room', roomId: 'start-room', gamemodeId: 'trick-attack', playerId: 'host' }); + sendRoom(router, guest.ws, { type: 'join_room', roomId: 'start-room', playerId: 'guest' }); + sendRoom(router, other.ws, { type: 'create_room', roomId: 'other-room', gamemodeId: 'free-skate', playerId: 'other' }); + host.messages.length = 0; + guest.messages.length = 0; + other.messages.length = 0; + + sendRoom(router, host.ws, { type: 'start_gamemode', roomId: 'start-room', playerId: 'host' }); + + expect(host.messages).toEqual([{ type: 'start_gamemode', roomId: 'start-room', gamemodeId: 'trick-attack', hostId: 'host' }]); + expect(guest.messages).toEqual([{ type: 'start_gamemode', roomId: 'start-room', gamemodeId: 'trick-attack', hostId: 'host' }]); + expect(other.messages).toEqual([]); + }); +}); diff --git a/src/server/rooms.ts b/src/server/rooms.ts new file mode 100644 index 0000000..a8b71d2 --- /dev/null +++ b/src/server/rooms.ts @@ -0,0 +1,153 @@ +import type { ServerWebSocket } from 'bun'; +import { + validateRoomClientMessage, + type RoomClientMessage, + type RoomServerMessage, + type RoomStateBroadcast, + type RoomSummary, +} from '../contracts/roomProtocol.ts'; +import { ensureRoom, getRoomSummaries, type PlayerConnection } from './multiplayer.ts'; +import type { ModularRouter } from './router.ts'; + +interface LobbyRoom { + roomId: string; + gamemodeId: string; + hostId: string; + members: Map>; +} + +const lobbyRooms = new Map(); +const memberRoomByPlayerId = new Map(); + +function parsePayload(payload: unknown): unknown { + if (typeof payload === 'string' || payload instanceof Buffer) { + try { return JSON.parse(String(payload)); } catch { return null; } + } + return payload; +} + +function send(ws: ServerWebSocket, message: RoomServerMessage): void { + ws.send(JSON.stringify(message)); +} + +function roomState(room: LobbyRoom): RoomStateBroadcast { + return { + type: 'room_state', + roomId: room.roomId, + gamemodeId: room.gamemodeId, + players: [...room.members.keys()], + }; +} + +function broadcast(room: LobbyRoom, message: RoomServerMessage): void { + for (const ws of room.members.values()) send(ws, message); +} + +function summaries(): RoomSummary[] { + const byId = new Map(); + for (const summary of getRoomSummaries()) byId.set(summary.roomId, summary); + for (const room of lobbyRooms.values()) { + const existing = byId.get(room.roomId); + byId.set(room.roomId, { + roomId: room.roomId, + gamemodeId: room.gamemodeId, + playerCount: Math.max(existing?.playerCount ?? 0, room.members.size), + }); + } + return [...byId.values()].sort((a, b) => a.roomId.localeCompare(b.roomId)); +} + +function getLobbyRoom(roomId: string, gamemodeId = 'free-skate', hostId = ''): LobbyRoom { + let room = lobbyRooms.get(roomId); + if (!room) { + room = { roomId, gamemodeId, hostId, members: new Map() }; + lobbyRooms.set(roomId, room); + } + ensureRoom(roomId, gamemodeId); + return room; +} + +function leaveCurrentRoom(playerId: string): LobbyRoom | null { + const currentRoomId = memberRoomByPlayerId.get(playerId); + if (!currentRoomId) return null; + const room = lobbyRooms.get(currentRoomId); + memberRoomByPlayerId.delete(playerId); + if (!room) return null; + room.members.delete(playerId); + if (room.hostId === playerId) room.hostId = room.members.keys().next().value ?? ''; + return room; +} + +function joinRoom(ws: ServerWebSocket, room: LobbyRoom, playerId: string): void { + const previous = leaveCurrentRoom(playerId); + if (previous) { + broadcast(previous, roomState(previous)); + if (previous.members.size === 0) lobbyRooms.delete(previous.roomId); + } + room.members.set(playerId, ws); + memberRoomByPlayerId.set(playerId, room.roomId); + if (!room.hostId) room.hostId = playerId; + broadcast(room, roomState(room)); +} + +function handleRoomMessage(ws: ServerWebSocket, message: RoomClientMessage): void { + if (message.type === 'list_rooms') { + send(ws, { type: 'room_list', rooms: summaries() }); + return; + } + + const roomId = message.roomId.trim(); + const playerId = message.playerId.trim(); + if (!roomId || !playerId) return; + + if (message.type === 'create_room') { + const gamemodeId = message.gamemodeId.trim() || 'free-skate'; + const room = getLobbyRoom(roomId, gamemodeId, playerId); + room.gamemodeId = gamemodeId; + room.hostId = playerId; + ensureRoom(roomId, gamemodeId); + joinRoom(ws, room, playerId); + return; + } + + const room = getLobbyRoom(roomId); + if (message.type === 'join_room') { + joinRoom(ws, room, playerId); + return; + } + + if (message.type === 'leave_room') { + const previous = leaveCurrentRoom(playerId); + if (previous) { + broadcast(previous, roomState(previous)); + if (previous.members.size === 0) lobbyRooms.delete(previous.roomId); + } + send(ws, { type: 'room_list', rooms: summaries() }); + return; + } + + if (message.type === 'start_gamemode') { + if (!room.members.has(playerId) || room.hostId !== playerId) return; + broadcast(room, { + type: 'start_gamemode', + roomId: room.roomId, + gamemodeId: room.gamemodeId, + hostId: playerId, + }); + } +} + +export function registerRoomsModule(router: ModularRouter): void { + router.registerHttp('GET', '/rooms', () => Response.json({ rooms: summaries() })); + + router.registerWebSocket('room', (ws, payload) => { + const parsed = validateRoomClientMessage(parsePayload(payload)); + if (!parsed.ok) return; + handleRoomMessage(ws, parsed.value); + }); +} + +export function resetRoomsForTests(): void { + lobbyRooms.clear(); + memberRoomByPlayerId.clear(); +} diff --git a/src/server/router.test.ts b/src/server/router.test.ts new file mode 100644 index 0000000..83f44a5 --- /dev/null +++ b/src/server/router.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import type { ServerWebSocket } from 'bun'; +import { ModularRouter } from './router.ts'; + +interface TestConnection { id: string } + +describe('ModularRouter', () => { + test('dispatches registered HTTP routes by method and path', async () => { + const router = new ModularRouter(); + router.registerHttp('GET', '/health', () => new Response('ok', { status: 204 })); + + const response = await router.handleHttp(new Request('http://localhost/health'), { + server: {} as never, + upgrade: () => false, + }); + expect(response?.status).toBe(204); + expect(await router.handleHttp(new Request('http://localhost/missing'), { server: {} as never, upgrade: () => false })).toBeUndefined(); + }); + + test('dispatches explicit WebSocket channels', () => { + const router = new ModularRouter(); + const seen: unknown[] = []; + router.registerWebSocket('agent', (_ws, payload) => seen.push(payload)); + + const handled = router.dispatchWebSocket({} as ServerWebSocket, { channel: 'agent', type: 'agent_status' }); + expect(handled).toBe(true); + expect(seen).toHaveLength(1); + }); + + test('defaults legacy messages without channel to multiplayer', () => { + const router = new ModularRouter(); + let messageType = ''; + router.registerWebSocket('multiplayer', (_ws, payload) => { + if (typeof payload === 'object' && payload && 'type' in payload) messageType = String(payload.type); + }); + + expect(router.dispatchWebSocket({} as ServerWebSocket, { type: 'join', room: 'lunar-park', name: 'Pup' })).toBe(true); + expect(messageType).toBe('join'); + expect(router.dispatchWebSocket({} as ServerWebSocket, { channel: 'unknown' })).toBe(false); + }); +}); diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 0000000..4742c5f --- /dev/null +++ b/src/server/router.ts @@ -0,0 +1,54 @@ +import type { Server, ServerWebSocket } from 'bun'; + +export interface HttpRouteContext { + server: Server; + upgrade: (data?: TConnection) => boolean; +} + +export type HttpRouteHandler = (request: Request, context: HttpRouteContext) => Response | Promise | undefined | Promise; +export type WebSocketHandler = (ws: ServerWebSocket, payload: unknown) => void; + +export interface WebSocketEnvelope { + channel?: string; + type?: string; + [key: string]: unknown; +} + +export class ModularRouter { + private readonly httpRoutes: Array<{ method: string; path: string; handler: HttpRouteHandler }> = []; + private readonly wsHandlers = new Map>(); + + registerHttp(method: string, path: string, handler: HttpRouteHandler): void { + this.httpRoutes.push({ method: method.toUpperCase(), path, handler }); + } + + registerWebSocket(channel: string, handler: WebSocketHandler): void { + this.wsHandlers.set(channel, handler); + } + + async handleHttp(request: Request, context: HttpRouteContext): Promise { + const url = new URL(request.url); + for (const route of this.httpRoutes) { + if (route.method === request.method.toUpperCase() && route.path === url.pathname) { + return await route.handler(request, context); + } + } + return undefined; + } + + dispatchWebSocket(ws: ServerWebSocket, payload: unknown): boolean { + const channel = this.channelFor(payload); + const handler = this.wsHandlers.get(channel); + if (!handler) return false; + handler(ws, payload); + return true; + } + + private channelFor(payload: unknown): string { + if (payload && typeof payload === 'object' && 'channel' in payload) { + const channel = (payload as WebSocketEnvelope).channel; + if (typeof channel === 'string' && channel.length > 0) return channel; + } + return 'multiplayer'; + } +} diff --git a/src/server/wallet.test.ts b/src/server/wallet.test.ts new file mode 100644 index 0000000..6dc0556 --- /dev/null +++ b/src/server/wallet.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'bun:test'; +import nacl from 'tweetnacl'; +import bs58 from 'bs58'; +import { ModularRouter } from './router.ts'; +import { WalletAuthService, registerWalletModule } from './wallet.ts'; + +function testWallet() { + const keypair = nacl.sign.keyPair(); + return { address: bs58.encode(keypair.publicKey), secretKey: keypair.secretKey }; +} + +describe('WalletAuthService', () => { + test('binds a signed challenge to player id and wallet address', () => { + const keypair = testWallet(); + const auth = new WalletAuthService(); + const challenge = auth.createChallenge('player-1', keypair.address, Date.parse('2026-07-09T00:00:00.000Z')); + const signature = Buffer.from(nacl.sign.detached(new TextEncoder().encode(challenge.message), keypair.secretKey)).toString('base64'); + + const session = auth.verifyChallenge({ playerId: 'player-1', walletAddress: keypair.address, nonce: challenge.nonce, signature }, Date.parse('2026-07-09T00:01:00.000Z')); + + expect(session).toMatchObject({ playerId: 'player-1', walletAddress: keypair.address }); + expect(auth.sessionForPlayer('player-1')?.walletAddress).toBe(keypair.address); + }); + + test('rejects signatures for a different player binding', () => { + const keypair = testWallet(); + const auth = new WalletAuthService(); + const challenge = auth.createChallenge('player-1', keypair.address, 0); + const signature = Buffer.from(nacl.sign.detached(new TextEncoder().encode(challenge.message), keypair.secretKey)).toString('base64'); + + expect(() => auth.verifyChallenge({ playerId: 'player-2', walletAddress: keypair.address, nonce: challenge.nonce, signature }, 1000)).toThrow('wallet challenge does not match'); + }); + + test('registers challenge and verify HTTP routes', async () => { + const keypair = testWallet(); + const router = new ModularRouter(); + registerWalletModule(router); + const challengeResponse = await router.handleHttp(new Request('http://localhost/wallet/challenge', { + method: 'POST', + body: JSON.stringify({ playerId: 'player-1', walletAddress: keypair.address }), + }), { server: {} as never, upgrade: () => false }); + const challenge = await challengeResponse?.json() as { nonce: string; message: string }; + const signature = Buffer.from(nacl.sign.detached(new TextEncoder().encode(challenge.message), keypair.secretKey)).toString('base64'); + + const verifyResponse = await router.handleHttp(new Request('http://localhost/wallet/verify', { + method: 'POST', + body: JSON.stringify({ playerId: 'player-1', walletAddress: keypair.address, nonce: challenge.nonce, signature }), + }), { server: {} as never, upgrade: () => false }); + + expect(challengeResponse?.status).toBe(200); + expect(verifyResponse?.status).toBe(200); + expect(await verifyResponse?.json()).toMatchObject({ playerId: 'player-1', walletAddress: keypair.address }); + }); +}); diff --git a/src/server/wallet.ts b/src/server/wallet.ts new file mode 100644 index 0000000..a7ce2f7 --- /dev/null +++ b/src/server/wallet.ts @@ -0,0 +1,94 @@ +import nacl from 'tweetnacl'; +import bs58 from 'bs58'; +import type { ModularRouter } from './router.ts'; + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +interface WalletChallenge { + playerId: string; + walletAddress: string; + nonce: string; + issuedAt: number; + message: string; +} + +export interface WalletSession { + playerId: string; + walletAddress: string; + authenticatedAt: string; +} + +export class WalletAuthService { + private readonly challenges = new Map(); + private readonly sessionsByPlayer = new Map(); + + createChallenge(playerId: string, walletAddress: string, now = Date.now()): WalletChallenge { + if (playerId.length === 0) throw new Error('playerId is required'); + const normalizedAddress = normalizeSolanaAddress(walletAddress); + const nonce = crypto.randomUUID(); + const issuedAtIso = new Date(now).toISOString(); + const message = `Lunar Pup wallet sign-in\nPlayer: ${playerId}\nWallet: ${normalizedAddress}\nNonce: ${nonce}\nIssued At: ${issuedAtIso}\nDevnet only.`; + const challenge = { playerId, walletAddress: normalizedAddress, nonce, issuedAt: now, message }; + this.challenges.set(nonce, challenge); + return challenge; + } + + verifyChallenge(input: { playerId: string; walletAddress: string; nonce: string; signature: string }, now = Date.now()): WalletSession { + const challenge = this.challenges.get(input.nonce); + if (!challenge) throw new Error('wallet challenge not found'); + if (now - challenge.issuedAt > CHALLENGE_TTL_MS) { + this.challenges.delete(input.nonce); + throw new Error('wallet challenge expired'); + } + const walletAddress = normalizeSolanaAddress(input.walletAddress); + if (challenge.playerId !== input.playerId || challenge.walletAddress !== walletAddress) throw new Error('wallet challenge does not match player or wallet'); + const signature = Buffer.from(input.signature, 'base64'); + const ok = nacl.sign.detached.verify(new TextEncoder().encode(challenge.message), signature, bs58.decode(walletAddress)); + if (!ok) throw new Error('wallet signature invalid'); + this.challenges.delete(input.nonce); + const session = { playerId: input.playerId, walletAddress, authenticatedAt: new Date(now).toISOString() }; + this.sessionsByPlayer.set(input.playerId, session); + return session; + } + + sessionForPlayer(playerId: string): WalletSession | undefined { + return this.sessionsByPlayer.get(playerId); + } +} + +export function normalizeSolanaAddress(walletAddress: string): string { + const decoded = bs58.decode(walletAddress); + if (decoded.length !== 32) throw new Error('walletAddress must be a 32-byte base58 Solana public key'); + return bs58.encode(decoded); +} + +export function registerWalletModule(router: ModularRouter, auth = new WalletAuthService()): WalletAuthService { + router.registerHttp('POST', '/wallet/challenge', async (request) => { + const body = await request.json() as { playerId?: unknown; walletAddress?: unknown }; + if (typeof body.playerId !== 'string' || typeof body.walletAddress !== 'string') return json({ error: 'playerId and walletAddress are required' }, 400); + try { + const challenge = auth.createChallenge(body.playerId, body.walletAddress); + return json({ playerId: challenge.playerId, walletAddress: challenge.walletAddress, nonce: challenge.nonce, message: challenge.message, expiresInMs: CHALLENGE_TTL_MS }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : 'failed to create wallet challenge' }, 400); + } + }); + + router.registerHttp('POST', '/wallet/verify', async (request) => { + const body = await request.json() as { playerId?: unknown; walletAddress?: unknown; nonce?: unknown; signature?: unknown }; + if (typeof body.playerId !== 'string' || typeof body.walletAddress !== 'string' || typeof body.nonce !== 'string' || typeof body.signature !== 'string') { + return json({ error: 'playerId, walletAddress, nonce, and signature are required' }, 400); + } + try { + const session = auth.verifyChallenge({ playerId: body.playerId, walletAddress: body.walletAddress, nonce: body.nonce, signature: body.signature }); + return json({ playerId: session.playerId, walletAddress: session.walletAddress, authenticatedAt: session.authenticatedAt }); + } catch (error) { + return json({ error: error instanceof Error ? error.message : 'wallet verification failed' }, 401); + } + }); + return auth; +} + +function json(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } }); +} diff --git a/src/solana/interfaces.ts b/src/solana/interfaces.ts new file mode 100644 index 0000000..a0832df --- /dev/null +++ b/src/solana/interfaces.ts @@ -0,0 +1,39 @@ +import type { CurrencyService } from '../contracts/services.ts'; + +export const DEVNET_RPC_URL = 'https://api.devnet.solana.com'; + +export interface DevnetMintResult { + mintAddress: string; + signature: string; +} + +export interface DevnetTransferResult { + signature: string; + sourceTokenAccount: string; + destinationTokenAccount: string; +} + +export interface SplTokenService extends CurrencyService { + readonly mintAddress: string; + mintToWallet(walletAddress: string, amount: number): Promise; + transferBetweenWallets(fromWalletAddress: string, toWalletAddress: string, amount: number): Promise; +} + +export interface CosmeticNftMetadata { + name: string; + symbol: string; + cosmeticPackageId: string; + uri: string; +} + +export interface CosmeticNftMintResult { + mintAddress: string; + signature: string; + metadataUri: string; + cosmeticPackageId: string; +} + +export interface CosmeticNftService { + mintCosmetic(walletAddress: string, cosmeticPackageId: string, options?: Partial>): Promise; + grantOwnedCosmetics(accountId: string, walletAddress: string): Promise; +} diff --git a/src/solana/metadata.ts b/src/solana/metadata.ts new file mode 100644 index 0000000..a6b9384 --- /dev/null +++ b/src/solana/metadata.ts @@ -0,0 +1,16 @@ +export function cosmeticMetadataUri(cosmeticPackageId: string): string { + const encoded = encodeURIComponent(cosmeticPackageId); + return `lunarpup://cosmetic/${encoded}?cosmeticPackageId=${encoded}`; +} + +export function cosmeticPackageIdFromUri(uri: string): string | undefined { + try { + const parsed = new URL(uri); + const fromQuery = parsed.searchParams.get('cosmeticPackageId'); + if (fromQuery) return fromQuery; + if (parsed.protocol === 'lunarpup:' && parsed.hostname === 'cosmetic') return decodeURIComponent(parsed.pathname.replace(/^\//, '')); + } catch { + return undefined; + } + return undefined; +} diff --git a/src/solana/nft.ts b/src/solana/nft.ts new file mode 100644 index 0000000..9f4490e --- /dev/null +++ b/src/solana/nft.ts @@ -0,0 +1,76 @@ +import { createNft, mplTokenMetadata } from '@metaplex-foundation/mpl-token-metadata'; +import { createUmi } from '@metaplex-foundation/umi-bundle-defaults'; +import { generateSigner, keypairIdentity, percentAmount, publicKey, type KeypairSigner, type Umi } from '@metaplex-foundation/umi'; +import { Connection, PublicKey } from '@solana/web3.js'; +import type { CurrencyService } from '../contracts/services.ts'; +import { DEVNET_RPC_URL, type CosmeticNftMintResult, type CosmeticNftService } from './interfaces.ts'; +import { cosmeticMetadataUri, cosmeticPackageIdFromUri } from './metadata.ts'; + +export interface SolanaNftServiceOptions { + umi?: Umi; + connection?: Connection; + rpcUrl?: string; + authority: KeypairSigner; + inventory: CurrencyService; +} + +interface MintedCosmeticRecord { + mintAddress: string; + ownerAddress: string; + cosmeticPackageId: string; + metadataUri: string; +} + +export class MetaplexDevnetNftService implements CosmeticNftService { + readonly umi: Umi; + readonly connection: Connection; + readonly authority: KeypairSigner; + readonly inventory: CurrencyService; + private readonly mintedByMint = new Map(); + + constructor(options: SolanaNftServiceOptions) { + this.connection = options.connection ?? new Connection(options.rpcUrl ?? DEVNET_RPC_URL, 'confirmed'); + this.authority = options.authority; + this.inventory = options.inventory; + this.umi = (options.umi ?? createUmi(options.rpcUrl ?? DEVNET_RPC_URL)).use(mplTokenMetadata()).use(keypairIdentity(options.authority)); + } + + async mintCosmetic(walletAddress: string, cosmeticPackageId: string, options: { name?: string; symbol?: string; uri?: string } = {}): Promise { + if (cosmeticPackageId.length === 0) throw new Error('cosmeticPackageId is required'); + const mint = generateSigner(this.umi); + const metadataUri = options.uri ?? cosmeticMetadataUri(cosmeticPackageId); + const builder = createNft(this.umi, { + mint, + name: options.name ?? `Lunar Pup ${cosmeticPackageId}`, + symbol: options.symbol ?? 'LPUP', + uri: metadataUri, + sellerFeeBasisPoints: percentAmount(0), + tokenOwner: publicKey(walletAddress), + }); + const result = await builder.sendAndConfirm(this.umi); + const signature = Buffer.from(result.signature).toString('base64'); + const mintAddress = mint.publicKey.toString(); + this.mintedByMint.set(mintAddress, { mintAddress, ownerAddress: walletAddress, cosmeticPackageId, metadataUri }); + return { mintAddress, signature, metadataUri, cosmeticPackageId }; + } + + async grantOwnedCosmetics(accountId: string, walletAddress: string): Promise { + const wallet = new PublicKey(walletAddress); + const tokenAccounts = await this.connection.getParsedTokenAccountsByOwner(wallet, { programId: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') }); + const granted: string[] = []; + for (const account of tokenAccounts.value) { + const info = account.account.data.parsed.info; + const mintAddress = String(info.mint); + const tokenAmount = info.tokenAmount; + const ownsExactlyOneNft = Number(tokenAmount.amount) >= 1 && Number(tokenAmount.decimals) === 0; + const record = this.mintedByMint.get(mintAddress); + if (!ownsExactlyOneNft || !record) continue; + const cosmeticPackageId = cosmeticPackageIdFromUri(record.metadataUri); + if (!cosmeticPackageId) continue; + await this.inventory.grantOwnedItem(accountId, cosmeticPackageId, `solana nft ownership ${mintAddress}`); + granted.push(cosmeticPackageId); + } + return [...new Set(granted)].sort(); + } +} + diff --git a/src/solana/solana.test.ts b/src/solana/solana.test.ts new file mode 100644 index 0000000..020dbd7 --- /dev/null +++ b/src/solana/solana.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { cosmeticMetadataUri, cosmeticPackageIdFromUri } from './metadata.ts'; + +describe('Solana offline contracts', () => { + test('cosmetic NFT metadata URI encodes package ids without RPC', () => { + const uri = cosmeticMetadataUri('moon board/legendary'); + + expect(uri).toBe('lunarpup://cosmetic/moon%20board%2Flegendary?cosmeticPackageId=moon%20board%2Flegendary'); + expect(cosmeticPackageIdFromUri(uri)).toBe('moon board/legendary'); + }); + + test('mainnet launch script has both fail-closed gates', () => { + const script = readFileSync('scripts/solana/launch-mainnet.ts', 'utf8'); + + expect(script).toContain('Human-only mainnet launch gate'); + expect(script).toContain('MAINNET_LAUNCH_CONFIRM'); + expect(script).toContain('YES_I_AM_SURE'); + expect(script).toContain("answer !== 'y'"); + expect(script).toContain('process.stdin.isTTY'); + }); + + test('Solana docs require mocked RPC for tests', () => { + const docs = readFileSync('docs/solana.md', 'utf8'); + + expect(docs).toContain('Tests must inject mocked RPC/UMI clients. They must not hit devnet.'); + expect(docs).toContain('https://api.devnet.solana.com'); + }); +}); diff --git a/src/solana/token.ts b/src/solana/token.ts new file mode 100644 index 0000000..9310d2f --- /dev/null +++ b/src/solana/token.ts @@ -0,0 +1,105 @@ +import { getAssociatedTokenAddress, createAssociatedTokenAccountInstruction, createTransferInstruction, mintTo, getAccount, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction, type Signer } from '@solana/web3.js'; +import type { CurrencyService } from '../contracts/services.ts'; +import { DEVNET_RPC_URL, type DevnetMintResult, type DevnetTransferResult, type SplTokenService } from './interfaces.ts'; + +export interface SolanaTokenServiceOptions { + connection?: Connection; + rpcUrl?: string; + payer: Signer; + mint: PublicKey | string; + authority?: Signer; + decimals?: number; + inventory?: CurrencyService; +} + +export class SolanaDevnetTokenService implements SplTokenService { + readonly connection: Connection; + readonly payer: Signer; + readonly mint: PublicKey; + readonly authority: Signer; + readonly decimals: number; + readonly inventory?: CurrencyService; + + constructor(options: SolanaTokenServiceOptions) { + this.connection = options.connection ?? new Connection(options.rpcUrl ?? DEVNET_RPC_URL, 'confirmed'); + this.payer = options.payer; + this.mint = typeof options.mint === 'string' ? new PublicKey(options.mint) : options.mint; + this.authority = options.authority ?? options.payer; + this.decimals = options.decimals ?? 0; + this.inventory = options.inventory; + } + + get mintAddress(): string { + return this.mint.toBase58(); + } + + async getBalance(accountId: string): Promise { + if (this.inventory) return await this.inventory.getBalance(accountId); + const owner = new PublicKey(accountId); + const ata = await getAssociatedTokenAddress(this.mint, owner, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID); + try { + const account = await getAccount(this.connection, ata, 'confirmed', TOKEN_PROGRAM_ID); + return Number(account.amount); + } catch (error) { + if (error instanceof Error && /could not find account|Failed to find account/i.test(error.message)) return 0; + throw error; + } + } + + async grant(accountId: string, amount: number, reason: string): Promise { + if (this.inventory) return await this.inventory.grant(accountId, amount, reason); + await this.mintToWallet(accountId, amount); + return await this.getBalance(accountId); + } + + async spend(accountId: string, amount: number, reason: string): Promise { + if (!this.inventory) throw new Error(`on-chain spend requires an inventory ledger or escrow policy; refused ${reason}`); + return await this.inventory.spend(accountId, amount, reason); + } + + async listOwnedItems(accountId: string): Promise { + return this.inventory ? await this.inventory.listOwnedItems(accountId) : []; + } + + async grantOwnedItem(accountId: string, cosmeticId: string, reason: string): Promise { + if (!this.inventory) return; + await this.inventory.grantOwnedItem(accountId, cosmeticId, reason); + } + + async mintToWallet(walletAddress: string, amount: number): Promise { + assertPositiveInteger(amount, 'mint amount'); + const destination = await getOrCreateAssociatedTokenAccount(this.connection, this.payer, this.mint, new PublicKey(walletAddress)); + const signature = await mintTo(this.connection, this.payer, this.mint, destination, this.authority, amount, [], undefined, TOKEN_PROGRAM_ID); + return { mintAddress: this.mintAddress, signature }; + } + + async transferBetweenWallets(fromWalletAddress: string, toWalletAddress: string, amount: number): Promise { + assertPositiveInteger(amount, 'transfer amount'); + if (!('secretKey' in this.payer)) throw new Error('transferBetweenWallets requires payer to sign for the source wallet in devnet tooling'); + const owner = new PublicKey(fromWalletAddress); + if (!owner.equals(this.payer.publicKey)) throw new Error('source wallet must match payer public key for direct devnet transfer'); + const sourceTokenAccount = await getOrCreateAssociatedTokenAccount(this.connection, this.payer, this.mint, owner); + const destinationTokenAccount = await getOrCreateAssociatedTokenAccount(this.connection, this.payer, this.mint, new PublicKey(toWalletAddress)); + const tx = new Transaction().add(createTransferInstruction(sourceTokenAccount, destinationTokenAccount, owner, amount, [], TOKEN_PROGRAM_ID)); + const signature = await sendAndConfirmTransaction(this.connection, tx, [this.payer as Keypair]); + return { signature, sourceTokenAccount: sourceTokenAccount.toBase58(), destinationTokenAccount: destinationTokenAccount.toBase58() }; + } +} + +async function getOrCreateAssociatedTokenAccount(connection: Connection, payer: Signer, mint: PublicKey, owner: PublicKey): Promise { + const ata = await getAssociatedTokenAddress(mint, owner, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID); + try { + await getAccount(connection, ata, 'confirmed', TOKEN_PROGRAM_ID); + return ata; + } catch (error) { + if (!(error instanceof Error) || !/could not find account|Failed to find account/i.test(error.message)) throw error; + } + const tx = new Transaction().add(createAssociatedTokenAccountInstruction(payer.publicKey, ata, owner, mint, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID)); + await sendAndConfirmTransaction(connection, tx, [payer]); + return ata; +} + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`); +} diff --git a/src/styles.css b/src/styles.css index 5c82ba3..c34c518 100644 --- a/src/styles.css +++ b/src/styles.css @@ -196,9 +196,10 @@ h1 { border-radius: 10px; padding: 10px 14px; font-size: 13px; - max-width: 320px; + max-width: 360px; box-shadow: 0 8px 30px rgba(0,0,0,0.3); - pointer-events: none; + pointer-events: auto; + z-index: 20; } #multiplayer-panel h2 { margin: 0 0 6px 0; @@ -230,6 +231,68 @@ h1 { border-radius: 3px; font-size: 10px; } +.mp-lobby { + display: grid; + gap: 8px; + margin-top: 8px; +} +.mp-lobby-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.mp-lobby button, +.mp-create input, +.mp-create select { + min-height: 40px; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 8px; + color: #fff; + background: rgba(255,255,255,0.08); +} +.mp-lobby button { + cursor: pointer; + padding: 8px 10px; +} +.mp-lobby button:focus-visible, +.mp-create input:focus-visible, +.mp-create select:focus-visible { + outline: 2px solid #a0c4ff; + outline-offset: 2px; +} +.mp-rooms { + display: grid; + gap: 6px; + max-height: 150px; + overflow: auto; +} +.mp-room { + display: grid; + gap: 2px; + text-align: left; +} +.mp-room small, +.mp-empty { + color: rgba(255,255,255,0.65); +} +.mp-room-selected { + border-color: rgba(128,255,114,0.7); +} +.mp-create { + display: grid; + gap: 6px; +} +.mp-create label { + display: grid; + gap: 4px; + color: rgba(255,255,255,0.75); +} +.mp-create input, +.mp-create select { + width: 100%; + padding: 0 8px; +} #minimap-panel { position: absolute; top: 20px; @@ -367,3 +430,361 @@ h1 { #update-notice button:hover { background: rgba(160,196,255,0.32); } + +#agent-hud { + position: fixed; + top: 20px; + left: 20px; + z-index: 20; + width: min(320px, calc(100vw - 40px)); + max-height: 42vh; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 14px; + background: rgba(10, 16, 28, 0.72); + color: #f8fbff; + box-shadow: 0 18px 48px rgba(0,0,0,0.32); + backdrop-filter: blur(12px); +} + +.agent-header { + padding: 12px 14px 10px; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.agent-header h2 { + margin: 0 0 6px; + font-size: 15px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.agent-status { + font-size: 12px; + color: rgba(255,255,255,0.68); +} + +.agent-session-list { + display: grid; + gap: 8px; + max-height: calc(42vh - 62px); + overflow-y: auto; + padding: 10px; +} + +.agent-empty { + padding: 12px; + border: 1px dashed rgba(255,255,255,0.16); + border-radius: 10px; + color: rgba(255,255,255,0.62); + font-size: 12px; +} + +.agent-row { + padding: 10px; + border: 1px solid rgba(255,255,255,0.1); + border-left: 3px solid #a0c4ff; + border-radius: 10px; + background: rgba(255,255,255,0.06); +} + +.agent-row.agent-needs_input { + border-left-color: #ffd166; + background: rgba(255, 209, 102, 0.12); +} + +.agent-row.agent-done { + border-left-color: #80ff72; + opacity: 0.82; +} + +.agent-row-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.agent-row-top strong { + min-width: 0; + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-pill { + flex: none; + border-radius: 999px; + padding: 2px 7px; + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.82); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.agent-message { + margin-bottom: 6px; + color: rgba(255,255,255,0.9); + font-size: 12px; + line-height: 1.35; +} + +.agent-meta { + overflow: hidden; + color: rgba(255,255,255,0.54); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-screen-pulse { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 19; + border: 8px solid rgba(255, 209, 102, 0.78); + box-shadow: inset 0 0 80px rgba(255, 209, 102, 0.36); +} + +@media (prefers-reduced-motion: no-preference) { + .agent-screen-pulse { + animation: agent-screen-pulse 340ms ease-out forwards; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-screen-pulse { + opacity: 0; + } +} + +@keyframes agent-screen-pulse { + 0% { + opacity: 0; + transform: scale(1.02); + } + 18% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1); + } +} + +#cosmetics-panel { + position: fixed; + right: 16px; + top: 190px; + width: min(340px, calc(100vw - 32px)); + max-height: min(520px, calc(100vh - 220px)); + overflow: auto; + color: #f8f9fa; + background: rgba(8, 12, 20, 0.78); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 16px; + padding: 14px; + box-shadow: 0 18px 44px rgba(0,0,0,0.28); + backdrop-filter: blur(14px); + z-index: 12; +} + +.cosmetics-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.cosmetics-header h2 { + margin: 0; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cosmetics-pill { + border-radius: 999px; + background: rgba(255,255,255,0.12); + padding: 5px 9px; + font-size: 12px; + color: rgba(255,255,255,0.86); +} + +.cosmetics-account, +.cosmetics-meta, +.cosmetics-empty { + margin: 0 0 10px; + color: rgba(255,255,255,0.72); + font-size: 12px; +} + +.cosmetics-list { + display: grid; + gap: 10px; +} + +.cosmetics-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 12px; + padding: 10px; + background: rgba(255,255,255,0.07); +} + +.cosmetics-card-title { + font-size: 14px; + font-weight: 700; +} + +.cosmetics-swatches { + display: flex; + gap: 5px; + margin-top: 7px; +} + +.cosmetics-swatch { + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,0.42); +} + +.cosmetics-button { + min-width: 72px; + min-height: 40px; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 10px; + background: rgba(76,201,240,0.2); + color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +.cosmetics-button:hover:not(:disabled), +.cosmetics-button:focus-visible { + background: rgba(76,201,240,0.34); +} + +.cosmetics-button:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; +} + +.cosmetics-button:disabled { + cursor: default; + opacity: 0.62; +} + +.cosmetics-error { + border-radius: 10px; + background: rgba(255,107,107,0.16); + color: #ffd6d6; + padding: 10px; + font-size: 12px; +} + +.cosmetics-skeleton { + height: 72px; + border-radius: 12px; + background: linear-gradient(90deg, rgba(255,255,255,0.07), rgba(255,255,255,0.14), rgba(255,255,255,0.07)); + background-size: 220% 100%; + margin-top: 10px; + animation: cosmetics-pulse 1.2s ease-in-out infinite; +} + +@keyframes cosmetics-pulse { + from { background-position: 220% 0; } + to { background-position: -20% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .cosmetics-skeleton { + animation: none; + } +} + +@media (max-width: 720px) { + #cosmetics-panel { + top: auto; + right: 12px; + bottom: 12px; + max-height: 42vh; + } + +#gamemode-panel { + position: absolute; + top: 170px; + right: 20px; + width: 280px; + color: #fff; + background: rgba(10, 10, 25, 0.72); + border: 1px solid rgba(255,255,255,0.14); + border-radius: 10px; + padding: 12px 14px; + font-size: 13px; + box-shadow: 0 8px 30px rgba(0,0,0,0.3); + pointer-events: auto; +} +#gamemode-panel h2 { + margin: 0 0 8px 0; + font-size: 16px; + color: #80ff72; +} +#gamemode-buttons { + display: flex; + gap: 8px; + margin-bottom: 8px; +} +#gamemode-buttons button, +#gamemode-results button { + flex: 1; + border: 1px solid rgba(255,255,255,0.18); + border-radius: 6px; + background: rgba(128,255,114,0.16); + color: #fff; + padding: 6px 8px; + cursor: pointer; +} +#gamemode-buttons button:hover, +#gamemode-results button:hover { + background: rgba(128,255,114,0.26); +} +#gamemode-status { + color: rgba(255,255,255,0.78); + font-family: monospace; + font-size: 12px; + line-height: 1.35; +} +#gamemode-results { + position: absolute; + left: 50%; + top: 50%; + width: 340px; + transform: translate(-50%, -50%); + color: #fff; + background: rgba(10, 10, 25, 0.9); + border: 1px solid rgba(128,255,114,0.35); + border-radius: 14px; + padding: 18px 20px; + text-align: center; + box-shadow: 0 18px 60px rgba(0,0,0,0.45); + pointer-events: auto; +} +#gamemode-results h2 { + margin: 0 0 10px 0; + color: #80ff72; +} +#gamemode-results p { + margin: 8px 0; + font-family: monospace; +} diff --git a/src/ui/agentHud.ts b/src/ui/agentHud.ts new file mode 100644 index 0000000..f1c0f21 --- /dev/null +++ b/src/ui/agentHud.ts @@ -0,0 +1,207 @@ +import type { AgentEvent, AgentEventType } from '../contracts/agentEvents.ts'; +import { getWsUrl } from '../net/protocol.ts'; + +type AgentStatus = 'running' | 'needs_input' | 'done'; + +interface AgentSessionView { + harness: string; + sessionId: string; + project: string; + message: string; + timestamp: string; + status: AgentStatus; +} + +interface AgentEventBroadcastMessage { + channel: 'agent-events'; + event: AgentEvent; +} + +const statusByType: Record = { + agent_session_start: 'running', + agent_status: 'running', + agent_needs_input: 'needs_input', + agent_done: 'done', +}; + +const sessions = new Map(); +let panelEl: HTMLDivElement | null = null; +let listEl: HTMLDivElement | null = null; +let statusEl: HTMLDivElement | null = null; +let notificationPermissionRequested = false; +let audioContext: AudioContext | null = null; +let socket: WebSocket | null = null; + +function isAgentEventBroadcastMessage(value: unknown): value is AgentEventBroadcastMessage { + if (!value || typeof value !== 'object') return false; + if (!('channel' in value) || value.channel !== 'agent-events') return false; + if (!('event' in value) || !value.event || typeof value.event !== 'object') return false; + return 'type' in value.event && 'sessionId' in value.event; +} + +function formatTime(timestamp: string): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function renderSessions(): void { + if (!listEl || !statusEl) return; + const ordered = [...sessions.values()].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + statusEl.textContent = ordered.length === 0 ? 'Waiting for agent events' : `${ordered.filter(session => session.status !== 'done').length} active · ${ordered.length} total`; + listEl.replaceChildren(); + + if (ordered.length === 0) { + const empty = document.createElement('div'); + empty.className = 'agent-empty'; + empty.textContent = 'No harness sessions yet.'; + listEl.appendChild(empty); + return; + } + + for (const session of ordered) { + const row = document.createElement('article'); + row.className = `agent-row agent-${session.status}`; + row.innerHTML = ` +
+ + +
+
+
+ `; + const title = row.querySelector('strong'); + const pill = row.querySelector('.agent-pill'); + const message = row.querySelector('.agent-message'); + const meta = row.querySelector('.agent-meta'); + if (title) title.textContent = session.project; + if (pill) pill.textContent = session.status.replace('_', ' '); + if (message) message.textContent = session.message; + if (meta) meta.textContent = `${session.harness} · ${session.sessionId} · ${formatTime(session.timestamp)}`; + listEl.appendChild(row); + } +} + +async function requestNotificationPermission(): Promise { + if (notificationPermissionRequested || !('Notification' in window)) return; + notificationPermissionRequested = true; + if (Notification.permission === 'default') await Notification.requestPermission(); +} + +function armNotificationPermissionRequest(): void { + const request = () => void requestNotificationPermission(); + window.addEventListener('pointerdown', request, { once: true }); + window.addEventListener('keydown', request, { once: true }); +} + +function sendNotification(event: AgentEvent): void { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + new Notification(`${event.project} needs input`, { + body: event.message, + tag: `agent-${event.sessionId}`, + silent: true, + }); +} + +function playDogBark(): void { + const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; + if (!AudioContextCtor) return; + audioContext ??= new AudioContextCtor(); + const startedAt = audioContext.currentTime; + + const noiseLength = Math.max(1, Math.floor(audioContext.sampleRate * 0.11)); + const noiseBuffer = audioContext.createBuffer(1, noiseLength, audioContext.sampleRate); + const samples = noiseBuffer.getChannelData(0); + for (let i = 0; i < samples.length; i += 1) samples[i] = Math.random() * 2 - 1; + + const noise = audioContext.createBufferSource(); + const noiseGain = audioContext.createGain(); + noise.buffer = noiseBuffer; + noiseGain.gain.setValueAtTime(0.0001, startedAt); + noiseGain.gain.exponentialRampToValueAtTime(0.34, startedAt + 0.015); + noiseGain.gain.exponentialRampToValueAtTime(0.0001, startedAt + 0.12); + noise.connect(noiseGain).connect(audioContext.destination); + noise.start(startedAt); + noise.stop(startedAt + 0.13); + + const oscillator = audioContext.createOscillator(); + const oscillatorGain = audioContext.createGain(); + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(220, startedAt); + oscillator.frequency.exponentialRampToValueAtTime(95, startedAt + 0.09); + oscillatorGain.gain.setValueAtTime(0.0001, startedAt); + oscillatorGain.gain.exponentialRampToValueAtTime(0.22, startedAt + 0.012); + oscillatorGain.gain.exponentialRampToValueAtTime(0.0001, startedAt + 0.1); + oscillator.connect(oscillatorGain).connect(audioContext.destination); + oscillator.start(startedAt); + oscillator.stop(startedAt + 0.11); +} + +function pulseScreen(): void { + const pulse = document.createElement('div'); + pulse.className = 'agent-screen-pulse'; + document.body.appendChild(pulse); + window.setTimeout(() => pulse.remove(), 360); +} + +function handleAgentEvent(event: AgentEvent): void { + sessions.set(event.sessionId, { + harness: event.harness, + sessionId: event.sessionId, + project: event.project, + message: event.message, + timestamp: event.timestamp, + status: statusByType[event.type], + }); + renderSessions(); + + if (event.type === 'agent_needs_input') { + pulseScreen(); + playDogBark(); + sendNotification(event); + } +} + +function connectAgentEvents(): void { + const wsUrl = getWsUrl(); + if (!wsUrl || socket) return; + socket = new WebSocket(wsUrl); + socket.addEventListener('open', () => { + socket?.send(JSON.stringify({ channel: 'agent-events', type: 'subscribe' })); + }); + socket.addEventListener('message', message => { + try { + const data = JSON.parse(String(message.data)); + if (isAgentEventBroadcastMessage(data)) handleAgentEvent(data.event); + } catch { + // Ignore malformed messages on the shared game WebSocket. + } + }); + socket.addEventListener('close', () => { + socket = null; + window.setTimeout(connectAgentEvents, 2000); + }); +} + +export function setupAgentHud(): void { + panelEl = document.createElement('div'); + panelEl.id = 'agent-hud'; + panelEl.innerHTML = ` +
+

Agent harness

+
Waiting for agent events
+
+
+ `; + document.body.appendChild(panelEl); + statusEl = panelEl.querySelector('#agent-status'); + listEl = panelEl.querySelector('#agent-session-list'); + renderSessions(); + armNotificationPermissionRequest(); + connectAgentEvents(); +} + +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} diff --git a/src/ui/cosmetics.ts b/src/ui/cosmetics.ts new file mode 100644 index 0000000..60c0c7e --- /dev/null +++ b/src/ui/cosmetics.ts @@ -0,0 +1,154 @@ +import type { CosmeticPackage, EquippedCosmetics } from '../cosmetics/registry.ts'; +import { applyLocalCosmetics } from '../game/cosmetics.ts'; +import { getApiBaseUrl } from '../net/protocol.ts'; +import { setLocalEquippedCosmetics } from '../game/multiplayer.ts'; + +interface InventoryPayload { + accountId: string; + balance: number; + ownedIds: string[]; + equipped: EquippedCosmetics; + catalog: CosmeticPackage[]; +} + +let panel: HTMLElement | null = null; +let state: InventoryPayload | null = null; +let errorText = ''; +let loading = false; +let accountId = ''; + +export function setupCosmeticsUI(): void { + accountId = localStorage.getItem('lunarpup.cosmetics.accountId') || `pup-${crypto.randomUUID().slice(0, 8)}`; + localStorage.setItem('lunarpup.cosmetics.accountId', accountId); + + const element = document.createElement('section'); + element.id = 'cosmetics-panel'; + element.setAttribute('aria-label', 'Cosmetics shop and inventory'); + document.body.appendChild(element); + panel = element; + renderLoading(); + void loadInventory(); +} + +async function loadInventory(): Promise { + loading = true; + errorText = ''; + renderLoading(); + try { + const response = await fetch(`${getApiBaseUrl()}/api/cosmetics/inventory?accountId=${encodeURIComponent(accountId)}`); + if (!response.ok) throw new Error(`Inventory failed (${response.status})`); + state = await response.json() as InventoryPayload; + applyEquipped(); + } catch (error) { + errorText = error instanceof Error ? error.message : String(error); + } finally { + loading = false; + render(); + } +} + +async function buy(cosmeticId: string): Promise { + await postAction('/api/cosmetics/buy', { accountId, cosmeticId }); +} + +async function equip(cosmeticId: string, slot: string): Promise { + await postAction('/api/cosmetics/equip', { accountId, cosmeticId, slot }); +} + +async function postAction(path: string, body: Record): Promise { + loading = true; + errorText = ''; + render(); + try { + const response = await fetch(`${getApiBaseUrl()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`); + state = payload as InventoryPayload; + applyEquipped(); + } catch (error) { + errorText = error instanceof Error ? error.message : String(error); + } finally { + loading = false; + render(); + } +} + +function applyEquipped(): void { + if (!state) return; + applyLocalCosmetics(state.equipped, state.catalog); + setLocalEquippedCosmetics(state.equipped); +} + +function renderLoading(): void { + if (!panel) return; + panel.innerHTML = ` +
+

Cosmetics

+ Loading… +
+ + + `; +} + +function render(): void { + if (!panel) return; + if (loading && !state) return renderLoading(); + if (errorText) { + panel.innerHTML = ` +

Cosmetics

+

Couldn’t load cosmetics. ${escapeHtml(errorText)}

+ + `; + panel.querySelector('#cosmetics-retry')?.addEventListener('click', () => void loadInventory()); + return; + } + if (!state || state.catalog.length === 0) { + panel.innerHTML = ` +

Cosmetics

+

No cosmetics are stocked yet.

+ `; + return; + } + + const owned = new Set(state.ownedIds); + panel.innerHTML = ` +
+

Cosmetics

+ ${state.balance} moon bones +
+ +
+ ${state.catalog.map(item => cosmeticCard(item, owned.has(item.id), state!.equipped[item.definition.slot] === item.id)).join('')} +
+ ${errorText ? `

${escapeHtml(errorText)}

` : ''} + `; + + panel.querySelectorAll('[data-buy]').forEach(button => { + button.addEventListener('click', () => void buy(button.dataset.buy!)); + }); + panel.querySelectorAll('[data-equip]').forEach(button => { + button.addEventListener('click', () => void equip(button.dataset.equip!, button.dataset.slot!)); + }); +} + +function cosmeticCard(item: CosmeticPackage, owned: boolean, equipped: boolean): string { + const colors = item.definition.visual.colors.map(color => ``).join(''); + const action = owned + ? `` + : ``; + return ` +
+
+
${escapeHtml(item.manifest.displayName)}
+
${item.definition.slot} · ${item.definition.rarity}
+
${colors}
+
+ ${action} +
+ `; +} + +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} diff --git a/src/ui/multiplayer.ts b/src/ui/multiplayer.ts index 51af92e..9bb85cb 100644 --- a/src/ui/multiplayer.ts +++ b/src/ui/multiplayer.ts @@ -1,20 +1,76 @@ +import type { RoomSummary } from '../contracts/roomProtocol.ts'; import type { MultiplayerStatus } from '../net/client.ts'; +import { getApiBaseUrl } from '../net/protocol.ts'; + +interface MultiplayerUIOptions { + enabled: boolean; + room: string; + name: string; + apiBase: string; + wsUrl?: string | null; + onJoinRoom: (roomId: string) => void; +} let statusEl: HTMLDivElement | null = null; let playersEl: HTMLDivElement | null = null; +let roomsEl: HTMLDivElement | null = null; +let selectedRoom = ''; +let uiOptions: MultiplayerUIOptions | null = null; + +export function setupMultiplayerUI(options?: MultiplayerUIOptions) { + uiOptions = options ?? null; + selectedRoom = options?.room ?? ''; -export function setupMultiplayerUI() { const panel = document.createElement('div'); panel.id = 'multiplayer-panel'; panel.innerHTML = ` -

🐾 Multiplayer

+

Multiplayer

Offline
-
Add ?multiplayer&room=your-room to the URL
+
+
+ Rooms + +
+
Loading rooms…
+
+ + + +
+
+
Choose a room to join, or keep free-skate drop-in with ?multiplayer.
`; document.body.appendChild(panel); statusEl = panel.querySelector('#mp-status'); playersEl = panel.querySelector('#mp-players'); + roomsEl = panel.querySelector('#mp-rooms'); + + panel.querySelector('#mp-refresh')?.addEventListener('click', () => void refreshRooms()); + panel.querySelector('#mp-create')?.addEventListener('submit', (event) => { + event.preventDefault(); + const roomInput = panel.querySelector('#mp-room-id'); + const gamemodeInput = panel.querySelector('#mp-gamemode'); + const roomId = roomInput?.value.trim() || 'lunar-park'; + const gamemodeId = gamemodeInput?.value.trim() || 'free-skate'; + void createRoom(roomId, gamemodeId); + }); + + void refreshRooms(); +} + +export function updateRoomBrowser(rooms: RoomSummary[]) { + renderRooms(rooms); } export function updateMultiplayerHint(html: string) { @@ -43,3 +99,63 @@ export function updateMultiplayerPlayers(localName: string, remoteNames: string[ ? `${all.length} pups: ${all.join(', ')}` : `Just you (${localName})`; } + +async function refreshRooms() { + if (!roomsEl) return; + roomsEl.textContent = 'Loading rooms…'; + try { + const res = await fetch(roomHttpUrl()); + if (!res.ok) throw new Error(`Room list failed (${res.status})`); + const body = await res.json() as { rooms?: RoomSummary[] }; + renderRooms(body.rooms ?? []); + } catch { + roomsEl.innerHTML = '
Room list unavailable. You can still use ?multiplayer for free-skate drop-in.
'; + } +} + +async function createRoom(roomId: string, gamemodeId: string) { + if (!uiOptions) return; + const target = uiOptions.wsUrl ?? `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; + const ws = new WebSocket(target); + ws.addEventListener('open', () => { + ws.send(JSON.stringify({ channel: 'room', type: 'create_room', roomId, gamemodeId, playerId: uiOptions?.name ?? 'Pup' })); + ws.close(); + joinRoom(roomId); + }, { once: true }); + ws.addEventListener('error', () => joinRoom(roomId), { once: true }); +} + +function roomHttpUrl() { + if (!uiOptions?.wsUrl) return `${getApiBaseUrl()}/rooms`; + const url = new URL(uiOptions.wsUrl); + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; + url.pathname = '/rooms'; + url.search = ''; + return url.href; +} + +function renderRooms(rooms: RoomSummary[]) { + if (!roomsEl) return; + if (rooms.length === 0) { + roomsEl.innerHTML = '
No rooms yet. Create one or drop into free skate.
'; + return; + } + + roomsEl.replaceChildren(...rooms.map((room) => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = room.roomId === selectedRoom ? 'mp-room mp-room-selected' : 'mp-room'; + item.innerHTML = `${escapeHtml(room.roomId)}${escapeHtml(room.gamemodeId)} · ${room.playerCount} pup${room.playerCount === 1 ? '' : 's'}`; + item.addEventListener('click', () => joinRoom(room.roomId)); + return item; + })); +} + +function joinRoom(roomId: string) { + selectedRoom = roomId; + uiOptions?.onJoinRoom(roomId); +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[char] ?? char)); +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..230bf93 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,3 @@ +// Bun 1.3.14 crashes when bigint-buffer loads its native N-API binding. +// Solana web3 works with bigint-buffer's browser/pure-JS path, so force that path in tests. +(process as typeof process & { browser?: boolean }).browser = true;