Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions projects/burger-miner-mvp/.env.example
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions projects/burger-miner-mvp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.next
dist
.env
.env.local
coverage
.DS_Store
*.log
76 changes: 76 additions & 0 deletions projects/burger-miner-mvp/README.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions projects/burger-miner-mvp/apps/api/package.json
Original file line number Diff line number Diff line change
@@ -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"}
}
49 changes: 49 additions & 0 deletions projects/burger-miner-mvp/apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -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<string,{userId:string,seed:number,startedAt:number,hash:number,objects:Set<string>}>();
const users = new Map<string,{id:string,email:string,name?:string}>();
const wallets = new Map<string,{hash:number,bp:number,xp:number}>();

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)});
1 change: 1 addition & 0 deletions projects/burger-miner-mvp/apps/api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","strict":true,"esModuleInterop":true,"skipLibCheck":true},"include":["src/**/*.ts"]}
6 changes: 6 additions & 0 deletions projects/burger-miner-mvp/apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import './styles.css';

export default function RootLayout({ children }: { children: ReactNode }) {
return <html lang="es"><body>{children}</body></html>;
}
46 changes: 46 additions & 0 deletions projects/burger-miner-mvp/apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client';

import { useEffect, useRef } from 'react';

export default function HomePage() {
const gameRef = useRef<HTMLDivElement>(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 <main><section className="hero"><p className="kicker">PLAY · MINE · EAT</p><h1>Burger Miner</h1><p>Google Login → juega → genera Game Hash → gana Burger Points → canjea comida real.</p><button>Continuar con Google</button></section><section className="game-shell"><div ref={gameRef} /></section></main>;
}
1 change: 1 addition & 0 deletions projects/burger-miner-mvp/apps/web/app/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions projects/burger-miner-mvp/apps/web/package.json
Original file line number Diff line number Diff line change
@@ -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"}
}
14 changes: 14 additions & 0 deletions projects/burger-miner-mvp/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
15 changes: 15 additions & 0 deletions projects/burger-miner-mvp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions projects/burger-miner-mvp/packages/database/package.json
Original file line number Diff line number Diff line change
@@ -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"}}
101 changes: 101 additions & 0 deletions projects/burger-miner-mvp/packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -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])
}
3 changes: 3 additions & 0 deletions projects/burger-miner-mvp/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
9 changes: 9 additions & 0 deletions projects/burger-miner-mvp/turbo.json
Original file line number Diff line number Diff line change
@@ -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"] }
}
}