From 9b04e9b4d0cab73a73655cbb100ddf236327d64f Mon Sep 17 00:00:00 2001 From: codespace-bot Date: Sat, 30 May 2026 13:52:48 +0000 Subject: [PATCH] feat(slot-machine): add slot-machine-service scaffold with provably-fair spins --- microservices/slot-machine-service/Dockerfile | 8 + microservices/slot-machine-service/README.md | 24 +++ .../slot-machine-service/docker-compose.yml | 8 + .../slot-machine-service/package.json | 24 +++ .../slot-machine-service/src/app.module.ts | 7 + .../slot-machine-service/src/main.ts | 13 ++ .../src/slot/dto/spin.dto.ts | 5 + .../src/slot/entities/history.entity.ts | 5 + .../src/slot/entities/reward.entity.ts | 5 + .../src/slot/entities/spin.entity.ts | 13 ++ .../src/slot/seed.service.ts | 33 ++++ .../src/slot/slot.controller.ts | 28 +++ .../src/slot/slot.module.ts | 10 ++ .../src/slot/slot.service.ts | 166 ++++++++++++++++++ .../slot-machine-service/tsconfig.json | 15 ++ 15 files changed, 364 insertions(+) create mode 100644 microservices/slot-machine-service/Dockerfile create mode 100644 microservices/slot-machine-service/README.md create mode 100644 microservices/slot-machine-service/docker-compose.yml create mode 100644 microservices/slot-machine-service/package.json create mode 100644 microservices/slot-machine-service/src/app.module.ts create mode 100644 microservices/slot-machine-service/src/main.ts create mode 100644 microservices/slot-machine-service/src/slot/dto/spin.dto.ts create mode 100644 microservices/slot-machine-service/src/slot/entities/history.entity.ts create mode 100644 microservices/slot-machine-service/src/slot/entities/reward.entity.ts create mode 100644 microservices/slot-machine-service/src/slot/entities/spin.entity.ts create mode 100644 microservices/slot-machine-service/src/slot/seed.service.ts create mode 100644 microservices/slot-machine-service/src/slot/slot.controller.ts create mode 100644 microservices/slot-machine-service/src/slot/slot.module.ts create mode 100644 microservices/slot-machine-service/src/slot/slot.service.ts create mode 100644 microservices/slot-machine-service/tsconfig.json diff --git a/microservices/slot-machine-service/Dockerfile b/microservices/slot-machine-service/Dockerfile new file mode 100644 index 0000000..b33cff7 --- /dev/null +++ b/microservices/slot-machine-service/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18-alpine +WORKDIR /app +COPY package.json tsconfig.json ./ +COPY src ./src +RUN npm install --production +RUN npm run build || true +EXPOSE 3333 +CMD ["node", "dist/main.js"] diff --git a/microservices/slot-machine-service/README.md b/microservices/slot-machine-service/README.md new file mode 100644 index 0000000..e1f3aef --- /dev/null +++ b/microservices/slot-machine-service/README.md @@ -0,0 +1,24 @@ +# Slot Machine Service + +Simple NestJS-style microservice that provides provably-fair slot-machine spins. + +Endpoints: +- GET /slot/seed-hash -> returns current server seed hash +- GET /slot/reveal-seed -> reveal last server seed (rotates seed) +- POST /slot/spin -> { userId, clientSeed, betAmount } +- GET /slot/history/:userId -> returns spin history + +Run locally: + +```bash +cd microservices/slot-machine-service +npm install +npm run dev +``` + +Docker: + +```bash +docker build -t slot-machine-service . +docker run -p 3333:3333 slot-machine-service +``` diff --git a/microservices/slot-machine-service/docker-compose.yml b/microservices/slot-machine-service/docker-compose.yml new file mode 100644 index 0000000..9226c83 --- /dev/null +++ b/microservices/slot-machine-service/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3.8' +services: + slot-machine-service: + build: . + ports: + - "3333:3333" + environment: + - NODE_ENV=production diff --git a/microservices/slot-machine-service/package.json b/microservices/slot-machine-service/package.json new file mode 100644 index 0000000..387d160 --- /dev/null +++ b/microservices/slot-machine-service/package.json @@ -0,0 +1,24 @@ +{ + "name": "slot-machine-service", + "version": "0.1.0", + "description": "Slot machine microservice with provably-fair spins", + "main": "dist/main.js", + "scripts": { + "start": "ts-node -r tsconfig-paths/register src/main.ts", + "build": "tsc -p tsconfig.json", + "start:prod": "node dist/main.js", + "dev": "ts-node-dev --respawn --transpile-only src/main.ts" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0" + }, + "devDependencies": { + "ts-node": "^10.9.1", + "ts-node-dev": "^2.0.0", + "typescript": "^5.0.0" + } +} diff --git a/microservices/slot-machine-service/src/app.module.ts b/microservices/slot-machine-service/src/app.module.ts new file mode 100644 index 0000000..7dcd076 --- /dev/null +++ b/microservices/slot-machine-service/src/app.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { SlotModule } from './slot/slot.module'; + +@Module({ + imports: [SlotModule], +}) +export class AppModule {} diff --git a/microservices/slot-machine-service/src/main.ts b/microservices/slot-machine-service/src/main.ts new file mode 100644 index 0000000..7cfc525 --- /dev/null +++ b/microservices/slot-machine-service/src/main.ts @@ -0,0 +1,13 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableCors(); + const port = process.env.PORT ? Number(process.env.PORT) : 3333; + await app.listen(port); + console.log(`Slot-machine-service running on http://localhost:${port}`); +} + +bootstrap(); diff --git a/microservices/slot-machine-service/src/slot/dto/spin.dto.ts b/microservices/slot-machine-service/src/slot/dto/spin.dto.ts new file mode 100644 index 0000000..afe8173 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/dto/spin.dto.ts @@ -0,0 +1,5 @@ +export class SpinDto { + userId: string; + clientSeed: string; + betAmount: number; +} diff --git a/microservices/slot-machine-service/src/slot/entities/history.entity.ts b/microservices/slot-machine-service/src/slot/entities/history.entity.ts new file mode 100644 index 0000000..bc9ace0 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/entities/history.entity.ts @@ -0,0 +1,5 @@ +export interface SpinHistoryEntry { + id: string; + userId: string; + result: any; +} diff --git a/microservices/slot-machine-service/src/slot/entities/reward.entity.ts b/microservices/slot-machine-service/src/slot/entities/reward.entity.ts new file mode 100644 index 0000000..9730bf1 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/entities/reward.entity.ts @@ -0,0 +1,5 @@ +export interface RewardDefinition { + symbol: string; + weight: number; + payoutMultiplier: number; +} diff --git a/microservices/slot-machine-service/src/slot/entities/spin.entity.ts b/microservices/slot-machine-service/src/slot/entities/spin.entity.ts new file mode 100644 index 0000000..9f869a2 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/entities/spin.entity.ts @@ -0,0 +1,13 @@ +export interface SpinResult { + userId: string; + nonce: number; + reels: string[][]; + payline: string[]; + payout: number; + multiplier: number; + timestamp: number; + serverSeedHash: string; + serverSeed?: string; + clientSeed: string; + proof: string; // HMAC or signature +} diff --git a/microservices/slot-machine-service/src/slot/seed.service.ts b/microservices/slot-machine-service/src/slot/seed.service.ts new file mode 100644 index 0000000..a9dd088 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/seed.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; + +@Injectable() +export class SeedService { + private serverSeed: string; + private serverSeedHash: string; + + constructor() { + this.rotateSeed(); + } + + rotateSeed() { + this.serverSeed = crypto.randomBytes(32).toString('hex'); + this.serverSeedHash = crypto.createHash('sha256').update(this.serverSeed).digest('hex'); + return this.serverSeedHash; + } + + getHash() { + return this.serverSeedHash; + } + + reveal() { + const seed = this.serverSeed; + // rotate after reveal to keep forward secrecy + this.rotateSeed(); + return seed; + } + + computeHMAC(message: string) { + return crypto.createHmac('sha256', this.serverSeed).update(message).digest('hex'); + } +} diff --git a/microservices/slot-machine-service/src/slot/slot.controller.ts b/microservices/slot-machine-service/src/slot/slot.controller.ts new file mode 100644 index 0000000..d807808 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/slot.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { SlotService } from './slot.service'; +import { SpinDto } from './dto/spin.dto'; + +@Controller('slot') +export class SlotController { + constructor(private readonly slotService: SlotService) {} + + @Get('seed-hash') + getSeedHash() { + return { seedHash: this.slotService.getSeedHash() }; + } + + @Get('reveal-seed') + reveal() { + return { seed: this.slotService.revealSeed() }; + } + + @Post('spin') + spin(@Body() body: SpinDto) { + return this.slotService.spin(body); + } + + @Get('history/:userId') + history(@Param('userId') userId: string) { + return this.slotService.getHistoryForUser(userId); + } +} diff --git a/microservices/slot-machine-service/src/slot/slot.module.ts b/microservices/slot-machine-service/src/slot/slot.module.ts new file mode 100644 index 0000000..5c84a85 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/slot.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SlotService } from './slot.service'; +import { SlotController } from './slot.controller'; +import { SeedService } from './seed.service'; + +@Module({ + providers: [SlotService, SeedService], + controllers: [SlotController], +}) +export class SlotModule {} diff --git a/microservices/slot-machine-service/src/slot/slot.service.ts b/microservices/slot-machine-service/src/slot/slot.service.ts new file mode 100644 index 0000000..3fb6798 --- /dev/null +++ b/microservices/slot-machine-service/src/slot/slot.service.ts @@ -0,0 +1,166 @@ +import { Injectable, ConflictException, BadRequestException } from '@nestjs/common'; +import { RewardDefinition } from './entities/reward.entity'; +import { SpinDto } from './dto/spin.dto'; +import * as crypto from 'crypto'; +import { SeedService } from './seed.service'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class SlotService { + private symbols: RewardDefinition[] = []; + private reels = 3; + private rows = 3; + private history: any[] = []; + private lastSpinByUser: Map = new Map(); + private cooldownMs = 3000; // 3 seconds cooldown + private dataDir: string; + + constructor(private seedService: SeedService) { + this.dataDir = path.join(process.cwd(), 'microservices', 'slot-machine-service', 'data'); + if (!fs.existsSync(this.dataDir)) fs.mkdirSync(this.dataDir, { recursive: true }); + this.loadDefaults(); + this.loadHistory(); + } + + private loadDefaults() { + this.symbols = [ + { symbol: 'CHERRY', weight: 30, payoutMultiplier: 2 }, + { symbol: 'LEMON', weight: 25, payoutMultiplier: 1.5 }, + { symbol: 'ORANGE', weight: 20, payoutMultiplier: 3 }, + { symbol: 'PLUM', weight: 12, payoutMultiplier: 5 }, + { symbol: 'BELL', weight: 8, payoutMultiplier: 10 }, + { symbol: '7', weight: 5, payoutMultiplier: 50 }, + ]; + } + + private loadHistory() { + const file = path.join(this.dataDir, 'history.json'); + try { + if (fs.existsSync(file)) { + const raw = fs.readFileSync(file, 'utf8'); + this.history = JSON.parse(raw || '[]'); + } + } catch (err) { + console.warn('Failed to load history', err); + this.history = []; + } + } + + private persistHistory() { + const file = path.join(this.dataDir, 'history.json'); + fs.writeFileSync(file, JSON.stringify(this.history, null, 2)); + } + + getSeedHash() { + return this.seedService.getHash(); + } + + revealSeed() { + return this.seedService.reveal(); + } + + getHistoryForUser(userId: string) { + return this.history.filter((h) => h.userId === userId).slice().reverse(); + } + + private weightedChoice(list: RewardDefinition[], rand: number) { + const total = list.reduce((s, i) => s + i.weight, 0); + let acc = 0; + const r = rand * total; + for (const item of list) { + acc += item.weight; + if (r <= acc) return item; + } + return list[list.length - 1]; + } + + private randomBytes(hex: string, offset: number, length: number) { + // take part of hex string + const slice = hex.slice(offset, offset + length); + return parseInt(slice, 16); + } + + spin(dto: SpinDto) { + if (!dto.userId) throw new BadRequestException('userId required'); + const now = Date.now(); + const last = this.lastSpinByUser.get(dto.userId) || 0; + if (now - last < this.cooldownMs) throw new ConflictException('Cooldown active'); + this.lastSpinByUser.set(dto.userId, now); + + const nonce = this.getNonceForUser(dto.userId); + const message = `${dto.clientSeed}:${nonce}:${dto.betAmount}`; + const proof = this.seedService.computeHMAC(message); + + // use proof hex to generate randomness + const reels: string[][] = []; + for (let r = 0; r < this.reels; r++) { + const reel: string[] = []; + for (let row = 0; row < this.rows; row++) { + // derive pseudo-random float from proof slice + const offset = (r * this.rows + row) * 8; + const intVal = this.randomBytes(proof, offset, 8) || 0; + const rand = (intVal % 1000000) / 1000000; + const choice = this.weightedChoice(this.symbols, rand); + reel.push(choice.symbol); + } + reels.push(reel); + } + + // simple payline: middle row across all reels + const payline = reels.map((r) => r[1]); + const { multiplier } = this.evaluatePayline(payline); + const payout = dto.betAmount * multiplier; + + const result = { + userId: dto.userId, + nonce, + reels, + payline, + payout, + multiplier, + timestamp: now, + serverSeedHash: this.seedService.getHash(), + clientSeed: dto.clientSeed, + proof, + animation: this.makeAnimationData(reels), + }; + + this.history.push({ id: uuidv4(), userId: dto.userId, result }); + this.persistHistory(); + + return result; + } + + private evaluatePayline(payline: string[]) { + // if all equal -> payout based on symbol multiplier + const first = payline[0]; + const allSame = payline.every((s) => s === first); + if (allSame) { + const def = this.symbols.find((s) => s.symbol === first); + return { multiplier: def ? def.payoutMultiplier : 1 }; + } + // two of a kind -> small multiplier + const counts = new Map(); + for (const s of payline) counts.set(s, (counts.get(s) || 0) + 1); + const max = Math.max(...Array.from(counts.values())); + if (max === 2) return { multiplier: 0.5 }; + return { multiplier: 0 }; + } + + private makeAnimationData(reels: string[][]) { + // for each reel: stop positions and duration + return reels.map((r, idx) => ({ + reel: idx, + stops: r, + durationMs: 800 + idx * 150, + })); + } + + private getNonceForUser(userId: string) { + // simple nonce based on count of previous spins + const count = this.history.filter((h) => h.userId === userId).length; + return count + 1; + } +} diff --git a/microservices/slot-machine-service/tsconfig.json b/microservices/slot-machine-service/tsconfig.json new file mode 100644 index 0000000..0cb4367 --- /dev/null +++ b/microservices/slot-machine-service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es2020", + "sourceMap": true, + "outDir": "dist", + "incremental": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +}