diff --git a/projects/burger-miner-mvp/.env.example b/projects/burger-miner-mvp/.env.example new file mode 100644 index 0000000..199e032 --- /dev/null +++ b/projects/burger-miner-mvp/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/burger_miner +REDIS_URL=redis://localhost:6379 +GOOGLE_CLIENT_ID= +JWT_SECRET=change-me +NEXT_PUBLIC_API_URL=http://localhost:4000 +FEATURE_BITCOIN=false +FEATURE_TOKEN=false diff --git a/projects/burger-miner-mvp/.gitignore b/projects/burger-miner-mvp/.gitignore new file mode 100644 index 0000000..4e26fc7 --- /dev/null +++ b/projects/burger-miner-mvp/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +dist +.env +.env.local +coverage +.DS_Store +*.log diff --git a/projects/burger-miner-mvp/README.md b/projects/burger-miner-mvp/README.md new file mode 100644 index 0000000..9b36c99 --- /dev/null +++ b/projects/burger-miner-mvp/README.md @@ -0,0 +1,76 @@ +# Burger Miner MVP + +**PLAY · MINE · EAT** — Google Login → arcade game → validated Game Hash → Burger Points → food marketplace → QR redemption. + +> Important: the browser does **not** mine Bitcoin. `Game Hash` is an internal gameplay metric. Real BTC mining/treasury and any transferable token are deliberately isolated behind future feature flags. + +## Architecture + +- `apps/web`: Next.js + Phaser mobile-first client. +- `apps/api`: Fastify API, Google ID-token verification, game sessions, reward validation, marketplace starter endpoints. +- `packages/database`: Prisma/PostgreSQL canonical schema with wallet ledger and redemptions. +- Redis/Postgres local infra via Docker Compose. + +## Core trust boundary + +`Game Client -> Session/Seed -> Event batches -> Server validation -> Ledger -> Burger Points -> Marketplace -> Signed redemption` + +The server is authoritative. Never trust client score, hash, wallet balances, product stock, or redemption state. + +## Quick start + +```bash +cp .env.example .env +pnpm install +docker compose up -d +pnpm --filter @burger/database generate +pnpm dev +``` + +Web: `http://localhost:3000` +API: `http://localhost:4000/health` + +## MVP status + +Included: +- Next.js landing and Phaser arcade prototype. +- Google ID token verification endpoint. +- Game-session creation, batched collect events, duplicate protection and finish validation. +- Prisma models for users, profiles, wallets, immutable ledger entries, sessions, merchants, products and redemptions. +- Marketplace starter endpoint. +- Feature flags for future Bitcoin/token layers. + +Next implementation steps: +1. Replace in-memory API stores with Prisma + Redis. +2. Add authenticated HttpOnly refresh sessions. +3. Make seeded map generation deterministic on both client/server. +4. Add movement/path anti-cheat and rate limiting. +5. Add transactional ledger writes and daily reward caps. +6. Build merchant scanner + signed QR redemption. +7. Add missions, referrals, leaderboards and passive miners. +8. Integrate a read-only BTC treasury adapter only after the loyalty economy is stable. + +## Suggested domain model + +Currencies: +- `XP`: progression only. +- `HASH`: game/mining-power metric, no direct monetary claim. +- `BP`: non-transferable loyalty points redeemable only in the marketplace. + +Feature flags: +- `FEATURE_BITCOIN=false` +- `FEATURE_TOKEN=false` + +## Security baseline + +- Verify Google ID tokens server-side (`aud`, `iss`, signature, expiry). +- HttpOnly/Secure/SameSite auth cookies for production. +- Rate-limit auth, sessions, event ingestion and redemption endpoints. +- BigInt/Decimal for economic values; never IEEE float balances. +- Atomic DB transactions for redemption and ledger mutations. +- Signed/opaque redemption tokens, single-use and expiring. +- WAF + CSP + CSRF protection + audit logs. + +## Product note + +Use original maze/character/art assets. Do not ship Pac-Man copyrighted visual assets or branding. diff --git a/projects/burger-miner-mvp/apps/api/package.json b/projects/burger-miner-mvp/apps/api/package.json new file mode 100644 index 0000000..a8b5950 --- /dev/null +++ b/projects/burger-miner-mvp/apps/api/package.json @@ -0,0 +1,7 @@ +{ + "name": "@burger/api", + "private": true, + "scripts": {"dev":"tsx watch src/main.ts","build":"tsc -p tsconfig.json","typecheck":"tsc --noEmit","lint":"echo lint"}, + "dependencies": {"@prisma/client":"^6.8.0","fastify":"^5.3.0","google-auth-library":"^9.15.1","ioredis":"^5.6.1","jsonwebtoken":"^9.0.2","zod":"^3.25.0"}, + "devDependencies": {"prisma":"^6.8.0","tsx":"^4.19.0","typescript":"^5.8.3","@types/jsonwebtoken":"^9.0.9"} +} diff --git a/projects/burger-miner-mvp/apps/api/src/main.ts b/projects/burger-miner-mvp/apps/api/src/main.ts new file mode 100644 index 0000000..d64f682 --- /dev/null +++ b/projects/burger-miner-mvp/apps/api/src/main.ts @@ -0,0 +1,49 @@ +import Fastify from 'fastify'; +import { OAuth2Client } from 'google-auth-library'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; + +const app = Fastify({ logger: true }); +const google = new OAuth2Client(process.env.GOOGLE_CLIENT_ID); +const sessions = new Map}>(); +const users = new Map(); +const wallets = new Map(); + +app.get('/health', async () => ({ ok: true, service: 'burger-miner-api' })); + +app.post('/auth/google', async (req, reply) => { + const { credential } = z.object({credential:z.string().min(20)}).parse(req.body); + const ticket = await google.verifyIdToken({idToken:credential,audience:process.env.GOOGLE_CLIENT_ID}); + const p = ticket.getPayload(); if (!p?.sub || !p.email) return reply.code(401).send({error:'INVALID_GOOGLE_TOKEN'}); + const user = users.get(p.sub) ?? {id:p.sub,email:p.email,name:p.name}; users.set(p.sub,user); + wallets.set(user.id, wallets.get(user.id) ?? {hash:0,bp:0,xp:0}); + const token = jwt.sign({sub:user.id}, process.env.JWT_SECRET ?? 'dev-secret', {expiresIn:'15m'}); + return { token, user }; +}); + +app.post('/game/sessions', async () => { + const id = crypto.randomUUID(); const seed = Math.floor(Math.random()*1_000_000_000); + sessions.set(id,{userId:'demo-user',seed,startedAt:Date.now(),hash:0,objects:new Set()}); + return {sessionId:id,seed,expiresAt:Date.now()+10*60_000}; +}); + +app.post('/game/sessions/:id/events', async (req, reply) => { + const s = sessions.get((req.params as any).id); if (!s) return reply.code(404).send({error:'SESSION_NOT_FOUND'}); + const body = z.object({events:z.array(z.object({type:z.literal('COLLECT'),objectId:z.string(),value:z.number().int().min(1).max(100)})).max(100)}).parse(req.body); + for (const e of body.events) { if (s.objects.has(e.objectId)) continue; s.objects.add(e.objectId); s.hash += e.value; } + return {accepted:true,hash:s.hash}; +}); + +app.post('/game/sessions/:id/finish', async (req, reply) => { + const id=(req.params as any).id; const s=sessions.get(id); if(!s)return reply.code(404).send({error:'SESSION_NOT_FOUND'}); + const elapsed=Date.now()-s.startedAt; const valid=elapsed>3_000 && elapsed<11*60_000; const bp=valid?Math.floor(s.hash/100):0; + const w=wallets.get(s.userId) ?? {hash:0,bp:0,xp:0}; if(valid){w.hash+=s.hash;w.bp+=bp;w.xp+=Math.floor(s.hash/2);wallets.set(s.userId,w);} sessions.delete(id); + return {validated:valid,hashEarned:valid?s.hash:0,burgerPoints:bp,xpEarned:valid?Math.floor(s.hash/2):0,trustScore:valid?90:20}; +}); + +app.get('/marketplace/products', async () => ([ + {id:'smash-001',name:'Double Smash Burger',priceBp:500,stock:100}, + {id:'combo-001',name:'Burger + Fries + Drink',priceBp:800,stock:60} +])); + +app.listen({port:Number(process.env.PORT ?? 4000),host:'0.0.0.0'}).catch(err=>{app.log.error(err);process.exit(1)}); diff --git a/projects/burger-miner-mvp/apps/api/tsconfig.json b/projects/burger-miner-mvp/apps/api/tsconfig.json new file mode 100644 index 0000000..e433781 --- /dev/null +++ b/projects/burger-miner-mvp/apps/api/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*.ts"]} diff --git a/projects/burger-miner-mvp/apps/web/app/layout.tsx b/projects/burger-miner-mvp/apps/web/app/layout.tsx new file mode 100644 index 0000000..bd7d7c9 --- /dev/null +++ b/projects/burger-miner-mvp/apps/web/app/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react'; +import './styles.css'; + +export default function RootLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/projects/burger-miner-mvp/apps/web/app/page.tsx b/projects/burger-miner-mvp/apps/web/app/page.tsx new file mode 100644 index 0000000..8b00422 --- /dev/null +++ b/projects/burger-miner-mvp/apps/web/app/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export default function HomePage() { + const gameRef = useRef(null); + + useEffect(() => { + let game: any; + (async () => { + const Phaser = await import('phaser'); + class BurgerScene extends Phaser.Scene { + score = 0; + label!: Phaser.GameObjects.Text; + player!: Phaser.Physics.Arcade.Sprite; + cursors!: Phaser.Types.Input.Keyboard.CursorKeys; + create() { + this.cameras.main.setBackgroundColor('#101014'); + this.player = this.physics.add.sprite(120, 180, '').setDisplaySize(28, 28); + this.player.setTint(0xffd84d); + this.player.setCollideWorldBounds(true); + this.label = this.add.text(16, 16, 'HASH 0', { fontSize: '22px', color: '#ffffff' }); + this.cursors = this.input.keyboard!.createCursorKeys(); + for (let i = 0; i < 18; i++) { + const burger = this.physics.add.sprite(60 + (i % 6) * 70, 80 + Math.floor(i / 6) * 90, '').setDisplaySize(18, 18); + burger.setTint(0xff7a18); + this.physics.add.overlap(this.player, burger, () => { + burger.destroy(); this.score += 10; this.label.setText(`HASH ${this.score}`); + }); + } + } + update() { + const speed = 170; this.player.setVelocity(0); + if (this.cursors.left.isDown) this.player.setVelocityX(-speed); + if (this.cursors.right.isDown) this.player.setVelocityX(speed); + if (this.cursors.up.isDown) this.player.setVelocityY(-speed); + if (this.cursors.down.isDown) this.player.setVelocityY(speed); + } + } + game = new Phaser.Game({type: Phaser.AUTO,width: 480,height: 640,parent: gameRef.current!,physics:{default:'arcade'},scene:[BurgerScene]}); + })(); + return () => game?.destroy(true); + }, []); + + return

PLAY · MINE · EAT

Burger Miner

Google Login → juega → genera Game Hash → gana Burger Points → canjea comida real.

; +} diff --git a/projects/burger-miner-mvp/apps/web/app/styles.css b/projects/burger-miner-mvp/apps/web/app/styles.css new file mode 100644 index 0000000..a1ae5c5 --- /dev/null +++ b/projects/burger-miner-mvp/apps/web/app/styles.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,sans-serif;background:#09090b;color:white}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#2a1608,#09090b 45%)}main{min-height:100vh;display:grid;grid-template-columns:1fr 520px;gap:48px;align-items:center;max-width:1200px;margin:auto;padding:48px}.kicker{letter-spacing:.18em;color:#ff9a2e;font-weight:800}.hero h1{font-size:72px;line-height:.9;margin:8px 0 24px}.hero p{font-size:20px;max-width:600px;color:#c8c8cf}.hero button{margin-top:20px;padding:16px 22px;border:0;border-radius:14px;background:white;font-weight:800}.game-shell{padding:18px;border-radius:28px;background:#17171c;box-shadow:0 30px 80px #0008}.game-shell canvas{width:100%!important;height:auto!important;border-radius:18px}@media(max-width:900px){main{grid-template-columns:1fr;padding:24px}.hero h1{font-size:52px}.game-shell{max-width:520px;width:100%;margin:auto}} diff --git a/projects/burger-miner-mvp/apps/web/package.json b/projects/burger-miner-mvp/apps/web/package.json new file mode 100644 index 0000000..63cda09 --- /dev/null +++ b/projects/burger-miner-mvp/apps/web/package.json @@ -0,0 +1,7 @@ +{ + "name": "@burger/web", + "private": true, + "scripts": {"dev":"next dev -p 3000","build":"next build","lint":"next lint","typecheck":"tsc --noEmit"}, + "dependencies": {"next":"^16.0.0","react":"^19.0.0","react-dom":"^19.0.0","phaser":"^4.0.0"}, + "devDependencies": {"typescript":"^5.8.3","@types/node":"^22.0.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0"} +} diff --git a/projects/burger-miner-mvp/docker-compose.yml b/projects/burger-miner-mvp/docker-compose.yml new file mode 100644 index 0000000..089c980 --- /dev/null +++ b/projects/burger-miner-mvp/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: burger_miner + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + volumes: ["pgdata:/var/lib/postgresql/data"] + redis: + image: redis:7-alpine + ports: ["6379:6379"] +volumes: + pgdata: diff --git a/projects/burger-miner-mvp/package.json b/projects/burger-miner-mvp/package.json new file mode 100644 index 0000000..01f4b31 --- /dev/null +++ b/projects/burger-miner-mvp/package.json @@ -0,0 +1,15 @@ +{ + "name": "burger-miner-mvp", + "private": true, + "packageManager": "pnpm@9.15.0", + "scripts": { + "dev": "turbo dev", + "build": "turbo build", + "lint": "turbo lint", + "typecheck": "turbo typecheck" + }, + "devDependencies": { + "turbo": "^2.5.6", + "typescript": "^5.8.3" + } +} diff --git a/projects/burger-miner-mvp/packages/database/package.json b/projects/burger-miner-mvp/packages/database/package.json new file mode 100644 index 0000000..1bb5a8c --- /dev/null +++ b/projects/burger-miner-mvp/packages/database/package.json @@ -0,0 +1 @@ +{"name":"@burger/database","private":true,"scripts":{"generate":"prisma generate","typecheck":"echo ok","build":"echo ok","lint":"echo ok"},"dependencies":{"@prisma/client":"^6.8.0"},"devDependencies":{"prisma":"^6.8.0"}} diff --git a/projects/burger-miner-mvp/packages/database/prisma/schema.prisma b/projects/burger-miner-mvp/packages/database/prisma/schema.prisma new file mode 100644 index 0000000..6a36420 --- /dev/null +++ b/projects/burger-miner-mvp/packages/database/prisma/schema.prisma @@ -0,0 +1,101 @@ +generator client { provider = "prisma-client-js" } +datasource db { provider = "postgresql" url = env("DATABASE_URL") } + +enum LedgerType { GAME_REWARD REFERRAL MARKET_REDEMPTION ADMIN_ADJUSTMENT PROMOTION SEASON_REWARD REVERSAL } +enum RedemptionStatus { PENDING REDEEMED EXPIRED CANCELLED FRAUD_REVIEW } + +model User { + id String @id @default(cuid()) + googleSub String @unique + email String @unique + name String? + avatarUrl String? + profile PlayerProfile? + wallet Wallet? + sessions GameSession[] + redemptions Redemption[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PlayerProfile { + id String @id @default(cuid()) + userId String @unique + username String? @unique + level Int @default(1) + xp BigInt @default(0) + totalHash BigInt @default(0) + miningPower BigInt @default(0) + energy Int @default(500) + maxEnergy Int @default(500) + user User @relation(fields:[userId], references:[id], onDelete: Cascade) +} + +model Wallet { + id String @id @default(cuid()) + userId String @unique + hashBalance BigInt @default(0) + burgerPoints BigInt @default(0) + lockedBalance BigInt @default(0) + user User @relation(fields:[userId], references:[id], onDelete: Cascade) + entries LedgerEntry[] +} + +model LedgerEntry { + id String @id @default(cuid()) + walletId String + type LedgerType + amount BigInt + currency String + referenceType String? + referenceId String? + balanceBefore BigInt + balanceAfter BigInt + createdAt DateTime @default(now()) + wallet Wallet @relation(fields:[walletId], references:[id], onDelete: Cascade) + @@index([walletId, createdAt]) +} + +model GameSession { + id String @id @default(cuid()) + userId String + seed BigInt + startedAt DateTime @default(now()) + endedAt DateTime? + validatedHash BigInt @default(0) + trustScore Int @default(100) + user User @relation(fields:[userId], references:[id], onDelete: Cascade) +} + +model Merchant { + id String @id @default(cuid()) + name String + active Boolean @default(true) + products Product[] +} + +model Product { + id String @id @default(cuid()) + merchantId String + name String + description String? + imageUrl String? + priceBp BigInt + stock Int + active Boolean @default(true) + merchant Merchant @relation(fields:[merchantId], references:[id], onDelete: Cascade) + redemptions Redemption[] +} + +model Redemption { + id String @id @default(cuid()) + userId String + productId String + codeHash String @unique + status RedemptionStatus @default(PENDING) + expiresAt DateTime + redeemedAt DateTime? + createdAt DateTime @default(now()) + user User @relation(fields:[userId], references:[id]) + product Product @relation(fields:[productId], references:[id]) +} diff --git a/projects/burger-miner-mvp/pnpm-workspace.yaml b/projects/burger-miner-mvp/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/projects/burger-miner-mvp/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/projects/burger-miner-mvp/turbo.json b/projects/burger-miner-mvp/turbo.json new file mode 100644 index 0000000..06db8d0 --- /dev/null +++ b/projects/burger-miner-mvp/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "dev": { "cache": false, "persistent": true }, + "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, + "lint": { "dependsOn": ["^lint"] }, + "typecheck": { "dependsOn": ["^typecheck"] } + } +}