diff --git a/README.md b/README.md index c8e3850..5f69707 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Un jeu multijoueur **Pierre-Feuille-Ciseaux** en 2D premium avec paris sur la bl | API & DB | Express + Prisma ORM + PostgreSQL 17 | | Cache | Redis | | Blockchain | Hardhat + Solidity + Ethers.js v6 | -| Audio | Howler.js | +| Audio | Web Audio API (procédural) | | CI/CD | Forgejo Actions | | Reverse Proxy | Nginx Proxy Manager | @@ -32,9 +32,21 @@ rps-royale/ │ └── contracts/ # Hardhat + Solidity ├── packages/ │ └── shared/ # Types et constantes partagés +├── docs/ # Documentation complète └── docker-compose.yml ``` +## Fonctionnalités + +- **Matchmaking temps réel** : appariement automatique par montant de mise +- **Mode IA** : joue contre un adversaire IA si personne n'est disponible +- **Sans wallet** : génération automatique d'une adresse guest +- **Anti-triche** : pattern commit-reveal via hash keccak256 +- **Audio procédural** : musique et SFX générés en temps réel (pas de fichiers audio) +- **Graphismes procéduraux** : sprites générés par Phaser (pas d'assets externes) +- **Animations premium** : zoom, shake, particules, clash cinématique +- **Smart contract** : 7/7 tests passants, déployé sur Hardhat local + ## Développement local ### Prérequis @@ -85,6 +97,14 @@ Le jeu repose sur un **pattern commit-reveal** blockchain pour garantir l'intég Voir [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) pour la documentation complète. +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [API Reference](docs/API_REFERENCE.md) +- [Smart Contract](docs/SMART_CONTRACT.md) +- [Déploiement](docs/DEPLOYMENT.md) +- [Game Design](docs/GAME_DESIGN.md) + ## Licence MIT diff --git a/apps/server/package.json b/apps/server/package.json index d1206ab..039e4de 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "ethers": "^6.13.0", "express": "^4.21.0", + "express-rate-limit": "^8.5.2", "ioredis": "^5.4.0", "socket.io": "^4.8.0" }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 50f7499..4e899e4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -2,6 +2,7 @@ import express from 'express'; import { createServer as createHttpServer } from 'http'; import { Server } from 'socket.io'; import cors from 'cors'; +import rateLimit from 'express-rate-limit'; import { setupSocketHandlers } from './socket/handlers.js'; import { apiRoutes } from './api/routes.js'; import { listenToContractEvents } from './blockchain/service.js'; @@ -10,6 +11,16 @@ export function createServer(port: number | string) { const app = express(); app.use(cors({ origin: process.env.CORS_ORIGIN || '*' })); app.use(express.json()); + + // Rate limiting for REST API + const apiLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later.' }, + }); + app.use('/api', apiLimiter); app.use('/api', apiRoutes); const httpServer = createHttpServer(app); diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 2a84bb7..3120da1 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -5,6 +5,16 @@ RUN npm install -g pnpm@9 COPY . . RUN pnpm install --frozen-lockfile RUN pnpm --filter @rps-royale/shared build + +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_CONTRACT_ADDRESS +ARG NEXT_PUBLIC_CHAIN_ID +ARG NEXT_PUBLIC_HARDHAT_RPC_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV NEXT_PUBLIC_CONTRACT_ADDRESS=${NEXT_PUBLIC_CONTRACT_ADDRESS} +ENV NEXT_PUBLIC_CHAIN_ID=${NEXT_PUBLIC_CHAIN_ID} +ENV NEXT_PUBLIC_HARDHAT_RPC_URL=${NEXT_PUBLIC_HARDHAT_RPC_URL} + RUN pnpm --filter @rps-royale/web build ENV NODE_ENV=production EXPOSE 3000 diff --git a/apps/web/src/app/play/PlayClient.tsx b/apps/web/src/app/play/PlayClient.tsx deleted file mode 100644 index 8593b42..0000000 --- a/apps/web/src/app/play/PlayClient.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { initGame } from '@/phaser/Game'; - -export default function PlayClient() { - const containerRef = useRef(null); - - useEffect(() => { - if (!containerRef.current) return; - const game = initGame('phaser-container'); - return () => { - game.destroy(true); - }; - }, []); - - return ( -
-
-
- ); -} diff --git a/apps/web/src/app/play/page.tsx b/apps/web/src/app/play/page.tsx index 60631a1..8a707ae 100644 --- a/apps/web/src/app/play/page.tsx +++ b/apps/web/src/app/play/page.tsx @@ -1,9 +1,63 @@ 'use client'; -import dynamic from 'next/dynamic'; - -const PlayClient = dynamic(() => import('./PlayClient'), { ssr: false }); +import { useEffect, useRef, useState } from 'react'; export default function PlayPage() { - return ; + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + const gameRef = useRef(null); + + useEffect(() => { + let mounted = true; + const init = async () => { + try { + const { initGame } = await import('@/phaser/Game'); + if (!mounted || !containerRef.current) return; + await new Promise((r) => setTimeout(r, 200)); + if (!mounted || !containerRef.current) return; + const game = initGame('phaser-container'); + gameRef.current = game; + setLoaded(true); + } catch (err: any) { + console.error('Failed to init Phaser game:', err); + setError(err?.message || 'Erreur de chargement du jeu'); + } + }; + init(); + return () => { + mounted = false; + if (gameRef.current) { + gameRef.current.destroy(true); + gameRef.current = null; + } + }; + }, []); + + if (error) { + return ( +
+

Erreur de chargement

+

{error}

+
+ ); + } + + return ( +
+ {!loaded && ( +
+

+ Chargement de l'arène... +

+
+ )} +
+ ); } diff --git a/apps/web/src/phaser/audio/AudioManager.ts b/apps/web/src/phaser/audio/AudioManager.ts new file mode 100644 index 0000000..ddc4757 --- /dev/null +++ b/apps/web/src/phaser/audio/AudioManager.ts @@ -0,0 +1,222 @@ +// Procedural audio using Web Audio API — no external files needed +class AudioManager { + private ctx: AudioContext | null = null; + private masterGain: GainNode | null = null; + private ambienceGain: GainNode | null = null; + private sfxGain: GainNode | null = null; + private ambienceOsc: OscillatorNode | null = null; + private ambienceLFO: OscillatorNode | null = null; + + private getCtx(): AudioContext { + if (!this.ctx) { + this.ctx = new AudioContext(); + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.value = 0.6; + this.masterGain.connect(this.ctx.destination); + + this.ambienceGain = this.ctx.createGain(); + this.ambienceGain.gain.value = 0; + this.ambienceGain.connect(this.masterGain); + + this.sfxGain = this.ctx.createGain(); + this.sfxGain.gain.value = 1.0; + this.sfxGain.connect(this.masterGain); + } + return this.ctx; + } + + resume() { + const ctx = this.getCtx(); + if (ctx.state === 'suspended') ctx.resume(); + } + + // Low drone for lobby background + startLobbyAmbience() { + const ctx = this.getCtx(); + if (this.ambienceOsc) return; + this.ambienceOsc = ctx.createOscillator(); + this.ambienceOsc.type = 'sine'; + this.ambienceOsc.frequency.value = 55; + this.ambienceLFO = ctx.createOscillator(); + this.ambienceLFO.type = 'sine'; + this.ambienceLFO.frequency.value = 0.2; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 8; + this.ambienceLFO.connect(lfoGain); + lfoGain.connect(this.ambienceOsc.frequency); + this.ambienceOsc.connect(this.ambienceGain!); + this.ambienceOsc.start(); + this.ambienceLFO.start(); + this.ambienceGain!.gain.setTargetAtTime(0.25, ctx.currentTime, 0.5); + } + + stopAmbience() { + if (!this.ctx || !this.ambienceGain) return; + this.ambienceGain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.3); + setTimeout(() => { + this.ambienceOsc?.stop(); + this.ambienceLFO?.stop(); + this.ambienceOsc = null; + this.ambienceLFO = null; + }, 500); + } + + // Tension riser for suspense phase + playSuspenseRiser() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(80, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 3.5); + gain.gain.setValueAtTime(0, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 1); + gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 3.8); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 4); + + // Heartbeat layer + this.playHeartbeat(3.5, 0.12); + } + + playHeartbeat(durationSec: number, intensity: number) { + const ctx = this.getCtx(); + const start = ctx.currentTime; + const count = Math.floor(durationSec / 0.45); + for (let i = 0; i < count; i++) { + const t = start + i * 0.45; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 60; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(intensity, t + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(t); + osc.stop(t + 0.25); + } + } + + // UI click sound + playClick() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(800, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.05); + gain.gain.setValueAtTime(0.2, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.1); + } + + // Commit confirmation sound + playCommit() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(200, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.15); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.3); + } + + // Victory fanfare + playVictory() { + const ctx = this.getCtx(); + const notes = [523.25, 659.25, 783.99, 1046.5]; + notes.forEach((freq, i) => { + const t = ctx.currentTime + i * 0.12; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.18, t + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(t); + osc.stop(t + 0.4); + }); + + // Noise burst + const bufferSize = ctx.sampleRate * 0.3; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(0.1, ctx.currentTime); + noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); + noise.connect(noiseGain); + noiseGain.connect(this.sfxGain!); + noise.start(); + } + + // Defeat sound + playDefeat() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(300, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.6); + gain.gain.setValueAtTime(0.18, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.7); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.8); + } + + // Draw sound + playDraw() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 440; + gain.gain.setValueAtTime(0.12, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.5); + } + + // Impact sound when result is shown + playImpact() { + const ctx = this.getCtx(); + const bufferSize = ctx.sampleRate * 0.2; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.03)); + } + const src = ctx.createBufferSource(); + src.buffer = buffer; + const gain = ctx.createGain(); + gain.gain.value = 0.25; + src.connect(gain); + gain.connect(this.sfxGain!); + src.start(); + } +} + +export const audio = new AudioManager(); diff --git a/apps/web/src/phaser/objects/AssetLoader.ts b/apps/web/src/phaser/objects/AssetLoader.ts new file mode 100644 index 0000000..9d13c5b --- /dev/null +++ b/apps/web/src/phaser/objects/AssetLoader.ts @@ -0,0 +1,106 @@ +import * as Phaser from 'phaser'; + +export function generateAssets(scene: Phaser.Scene) { + const g = scene.make.graphics({ x: 0, y: 0 }); + + // Rock — golem-like hexagon + g.fillStyle(0x64748b, 1); + g.fillCircle(64, 64, 56); + g.fillStyle(0x475569, 1); + g.fillCircle(64, 64, 44); + g.fillStyle(0x94a3b8, 1); + g.fillCircle(48, 48, 12); + g.fillCircle(80, 48, 12); + g.fillStyle(0x1e293b, 1); + g.fillCircle(48, 48, 6); + g.fillCircle(80, 48, 6); + g.generateTexture('rock', 128, 128); + g.clear(); + + // Paper — flowing scroll / leaf shape + g.fillStyle(0x22c55e, 1); + g.beginPath(); + g.moveTo(64, 8); + g.lineTo(120, 40); + g.lineTo(100, 120); + g.lineTo(28, 120); + g.lineTo(8, 40); + g.closePath(); + g.fillPath(); + g.fillStyle(0x15803d, 1); + g.beginPath(); + g.moveTo(64, 24); + g.lineTo(100, 48); + g.lineTo(88, 104); + g.lineTo(40, 104); + g.lineTo(28, 48); + g.closePath(); + g.fillPath(); + g.generateTexture('paper', 128, 128); + g.clear(); + + // Scissors — blade crossed shape + g.lineStyle(8, 0xef4444, 1); + g.beginPath(); + g.moveTo(20, 20); + g.lineTo(108, 108); + g.strokePath(); + g.beginPath(); + g.moveTo(108, 20); + g.lineTo(20, 108); + g.strokePath(); + g.fillStyle(0xfca5a5, 1); + g.fillCircle(64, 64, 18); + g.fillStyle(0xef4444, 1); + g.fillCircle(64, 64, 10); + g.generateTexture('scissors', 128, 128); + g.clear(); + + // Avatar placeholder (glowing orb) + g.fillStyle(0x06b6d4, 1); + g.fillCircle(64, 64, 60); + g.fillStyle(0x22d3ee, 1); + g.fillCircle(64, 64, 48); + g.fillStyle(0x0f172a, 1); + g.fillCircle(64, 64, 36); + g.generateTexture('avatar', 128, 128); + g.clear(); + + // AI avatar (purple orb) + g.fillStyle(0xa855f7, 1); + g.fillCircle(64, 64, 60); + g.fillStyle(0xc084fc, 1); + g.fillCircle(64, 64, 48); + g.fillStyle(0x0f172a, 1); + g.fillCircle(64, 64, 36); + g.generateTexture('avatarAI', 128, 128); + g.clear(); + + // VS badge + g.fillStyle(0xf59e0b, 1); + g.fillCircle(32, 32, 28); + g.fillStyle(0x78350f, 1); + g.fillCircle(32, 32, 22); + g.generateTexture('vs', 64, 64); + g.clear(); + + // Spark particle + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('spark', 8, 8); + g.clear(); + + // Flare particle + g.fillStyle(0xffffff, 1); + g.fillCircle(8, 8, 8); + g.generateTexture('flare', 16, 16); + g.clear(); + + // Ring glow + g.lineStyle(4, 0x22d3ee, 1); + g.strokeCircle(64, 64, 60); + g.generateTexture('ring', 128, 128); + g.clear(); + + g.destroy(); +} diff --git a/apps/web/src/phaser/scenes/ArenaScene.ts b/apps/web/src/phaser/scenes/ArenaScene.ts index c62d423..2e175d8 100644 --- a/apps/web/src/phaser/scenes/ArenaScene.ts +++ b/apps/web/src/phaser/scenes/ArenaScene.ts @@ -3,6 +3,7 @@ import { UIButton } from '../objects/UIButton'; import { socket } from '@/lib/socket'; import { keccak256, solidityPacked, parseEther, BrowserProvider, Contract } from 'ethers'; import { Choice, MatchStatus, RPSArenaAbi } from '@rps-royale/shared'; +import { audio } from '../audio/AudioManager'; interface ArenaData { match: { @@ -26,8 +27,14 @@ export class ArenaScene extends Phaser.Scene { private infoText!: Phaser.GameObjects.Text; private choiceButtons: UIButton[] = []; private actionButton!: UIButton; - private particles!: any; private phase: 'commit' | 'reveal' = 'commit'; + private p1Avatar!: Phaser.GameObjects.Image; + private p2Avatar!: Phaser.GameObjects.Image; + private p1Label!: Phaser.GameObjects.Text; + private p2Label!: Phaser.GameObjects.Text; + private p1Lock!: Phaser.GameObjects.Text; + private p2Lock!: Phaser.GameObjects.Text; + private waitingIndicator!: Phaser.GameObjects.Text; constructor() { super({ key: 'ArenaScene' }); @@ -54,14 +61,50 @@ export class ArenaScene extends Phaser.Scene { return this.matchId.startsWith('ai_'); } + createCyberpunkBackground(width: number, height: number) { + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + const g = this.add.graphics(); + g.lineStyle(1, 0x1e293b, 0.6); + for (let i = 0; i < 12; i++) { + const y = height / 2 + i * 32; + g.lineBetween(0, y, width, y); + } + const cx = width / 2; + for (let i = -10; i <= 10; i++) { + const x = cx + i * 80; + g.lineStyle(1, 0x1e293b, 0.4); + g.lineBetween(x, height / 2, cx + i * 20, height); + } + g.lineStyle(2, 0x06b6d4, 0.8); + g.lineBetween(0, height / 2, width, height / 2); + + const colColors = [0x06b6d4, 0xa855f7, 0xec4899]; + for (let i = 0; i < 6; i++) { + const x = (i + 0.5) * (width / 6); + const color = colColors[i % 3]; + this.add.rectangle(x, height / 2 - 100, 4, height / 2 - 80, color).setAlpha(0.3); + } + + this.add.particles(0, 0, 'spark', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 180, + lifespan: 2000, + alpha: { start: 0.25, end: 0 }, + scale: { start: 0.4, end: 0 }, + tint: [0x06b6d4, 0xa855f7], + }); + } + create() { const { width, height } = this.scale; - // Background gradient feel via dark rects - this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + this.createCyberpunkBackground(width, height); this.add - .text(width / 2, 40, `TABLE #${this.matchId.slice(-6)}`, { + .text(width / 2, 36, `TABLE #${this.matchId.slice(-6)}`, { fontSize: '28px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', @@ -70,48 +113,97 @@ export class ArenaScene extends Phaser.Scene { .setOrigin(0.5); this.infoText = this.add - .text(width / 2, 80, `Mise: 0.01 ETH | En attente du commit`, { + .text(width / 2, 76, `Mise: ${this.betAmount} ETH | En attente du commit`, { fontSize: '16px', color: '#94a3b8', }) .setOrigin(0.5); this.statusText = this.add - .text(width / 2, height / 2, 'CHOISIS TON SIGNE', { - fontSize: '32px', + .text(width / 2, height / 2 - 60, 'CHOISIS TON SIGNE', { + fontSize: '36px', color: '#22d3ee', fontStyle: 'bold', }) .setOrigin(0.5); - // Choice buttons + // Player avatars with lock indicators + const p1Name = this.isPlayer1 ? 'Toi' : (this.isAIMatch ? 'IA' : 'Adversaire'); + const p2Name = this.isPlayer1 ? (this.isAIMatch ? 'IA' : 'Adversaire') : 'Toi'; + + this.p1Avatar = this.add.image(140, height / 2 - 40, 'avatar').setScale(1.0); + this.p2Avatar = this.add.image(width - 140, height / 2 - 40, this.isAIMatch ? 'avatarAI' : 'avatar').setScale(1.0); + + this.add.image(140, height / 2 - 40, 'ring').setScale(1.1).setAlpha(0.6); + this.add.image(width - 140, height / 2 - 40, 'ring').setScale(1.1).setAlpha(0.6); + + this.p1Label = this.add.text(140, height / 2 + 50, p1Name, { fontSize: '16px', color: '#cbd5e1' }).setOrigin(0.5); + this.p2Label = this.add.text(width - 140, height / 2 + 50, p2Name, { fontSize: '16px', color: '#cbd5e1' }).setOrigin(0.5); + + this.p1Lock = this.add.text(140, height / 2 - 90, '', { fontSize: '24px', color: '#22c55e' }).setOrigin(0.5); + this.p2Lock = this.add.text(width - 140, height / 2 - 90, '', { fontSize: '24px', color: '#22c55e' }).setOrigin(0.5); + + this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.0).setAlpha(0.8); + + // Waiting for opponent text (initially hidden) + this.waitingIndicator = this.add + .text(width / 2, height / 2 + 10, '', { fontSize: '14px', color: '#f59e0b' }) + .setOrigin(0.5) + .setAlpha(0); + + // Choice buttons with icons const choices = [ - { label: 'PIERRE', value: Choice.Rock }, - { label: 'FEUILLE', value: Choice.Paper }, - { label: 'CISEAUX', value: Choice.Scissors }, + { label: 'PIERRE', value: Choice.Rock, tex: 'rock' }, + { label: 'FEUILLE', value: Choice.Paper, tex: 'paper' }, + { label: 'CISEAUX', value: Choice.Scissors, tex: 'scissors' }, ]; choices.forEach((c, i) => { - const btn = new UIButton(this, width / 2 - 280 + i * 280, height / 2 + 60, c.label, () => { + const x = width / 2 - 280 + i * 280; + const y = height / 2 + 100; + const icon = this.add.image(x, y - 50, c.tex).setScale(0.6).setAlpha(0.8); + const btn = new UIButton(this, x, y, c.label, () => { + audio.playClick(); this.selectChoice(c.value); + choices.forEach((cc, ii) => { + (this.children.getByName(`choiceIcon_${ii}`) as Phaser.GameObjects.Image)?.setAlpha(ii === i ? 1 : 0.3); + }); }); + icon.setName(`choiceIcon_${i}`); this.choiceButtons.push(btn); }); - // Action button (commit / reveal) - this.actionButton = new UIButton(this, width / 2, height / 2 + 160, 'Valider le Commit', () => { - if (this.phase === 'commit') this.sendCommit(); - else if (this.phase === 'reveal') this.sendReveal(); - }); + this.actionButton = new UIButton(this, width / 2, height / 2 + 220, 'Valider le Commit', () => { + if (this.phase === 'commit') { + audio.playCommit(); + this.sendCommit(); + } else if (this.phase === 'reveal') { + audio.playCommit(); + this.sendReveal(); + } + }, 280); this.actionButton.setDisabled(true); - // Particle emitter for atmosphere (initially off) - this.createParticles(); - - // Socket listeners - socket.on('match:commitReceived', (data: { matchId: string }) => { + socket.on('match:commitReceived', (data: { matchId: string; playerAddress: string }) => { if (data.matchId !== this.matchId) return; + const isMe = data.playerAddress.toLowerCase() === this.playerAddress.toLowerCase(); + if (isMe) { + this.p1Lock.setText('🔒'); + this.tweens.add({ targets: this.p1Lock, scaleX: 1.5, scaleY: 1.5, duration: 200, yoyo: true }); + } else { + this.p2Lock.setText('🔒'); + this.tweens.add({ targets: this.p2Lock, scaleX: 1.5, scaleY: 1.5, duration: 200, yoyo: true }); + } this.infoText.setText('Un joueur a commité... en attente du second'); + this.waitingIndicator.setText('En attente de l\'adversaire...').setAlpha(1); + this.tweens.add({ + targets: this.waitingIndicator, + alpha: 0.4, + duration: 800, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); }); socket.on('match:suspenseStart', (matchId: string) => { @@ -202,40 +294,44 @@ export class ArenaScene extends Phaser.Scene { const { width, height } = this.scale; this.statusText.setText('SUSPENSE').setColor('#a855f7'); this.infoText.setText('Les deux joueurs ont commité. Prépare-toi...'); + this.waitingIndicator.setAlpha(0); + this.tweens.killTweensOf(this.waitingIndicator); - // Camera zoom and shake - this.cameras.main.zoomTo(1.1, 2000, 'Sine.easeInOut'); - this.cameras.main.shake(3000, 0.005); + audio.playSuspenseRiser(); - // Intense particles - this.particles.setFrequency(30); - this.particles.setLifespan(2000); + this.cameras.main.zoomTo(1.15, 2000, 'Sine.easeInOut'); + this.cameras.main.shake(3000, 0.008); + + // Both avatars pulse with energy + this.tweens.add({ targets: this.p1Avatar, scaleX: 1.3, scaleY: 1.3, duration: 500, yoyo: true, repeat: 3 }); + this.tweens.add({ targets: this.p2Avatar, scaleX: 1.3, scaleY: 1.3, duration: 500, yoyo: true, repeat: 3 }); - // Flash effect const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff).setAlpha(0); this.tweens.add({ targets: flash, - alpha: 0.15, + alpha: 0.12, duration: 200, yoyo: true, repeat: 6, onComplete: () => flash.destroy(), }); - // Countdown text let countdown = 4; const countdownText = this.add - .text(width / 2, height / 2 - 100, `${countdown}`, { - fontSize: '96px', + .text(width / 2, height / 2 - 120, `${countdown}`, { + fontSize: '120px', color: '#f8fafc', fontStyle: '900', }) .setOrigin(0.5) - .setAlpha(0); + .setAlpha(0) + .setScale(0.8); this.tweens.add({ targets: countdownText, alpha: 1, + scaleX: 1.2, + scaleY: 1.2, duration: 500, repeat: 3, yoyo: true, @@ -280,25 +376,6 @@ export class ArenaScene extends Phaser.Scene { this.statusText.setText('Révélation envoyée...'); } - createParticles() { - const graphics = this.make.graphics({ x: 0, y: 0 }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(4, 4, 4); - graphics.generateTexture('spark', 8, 8); - graphics.destroy(); - - this.particles = this.add.particles(0, 0, 'spark', { - x: { min: 0, max: this.scale.width }, - y: { min: 0, max: this.scale.height }, - quantity: 1, - frequency: 200, - lifespan: 2000, - alpha: { start: 0.2, end: 0 }, - scale: { start: 0.4, end: 0 }, - tint: [0x06b6d4, 0xa855f7], - }); - } - shutdown() { socket.off('match:commitReceived'); socket.off('match:suspenseStart'); diff --git a/apps/web/src/phaser/scenes/BootScene.ts b/apps/web/src/phaser/scenes/BootScene.ts index 1b980b5..3141a75 100644 --- a/apps/web/src/phaser/scenes/BootScene.ts +++ b/apps/web/src/phaser/scenes/BootScene.ts @@ -1,55 +1,81 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; +import { generateAssets } from '../objects/AssetLoader'; +import { audio } from '../audio/AudioManager'; export class BootScene extends Phaser.Scene { constructor() { super({ key: 'BootScene' }); } + preload() { + generateAssets(this); + } + create() { const { width, height } = this.scale; + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + // Animated background stars/particles + this.add.particles(0, 0, 'flare', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 80, + lifespan: 4000, + alpha: { start: 0.25, end: 0 }, + scale: { start: 0.6, end: 0 }, + tint: [0x06b6d4, 0xa855f7, 0xec4899], + }); + + // Pulsing glow behind title + const glow = this.add.circle(width / 2, height / 2 - 120, 180, 0x06b6d4, 0.15); + this.tweens.add({ + targets: glow, + scaleX: 1.3, + scaleY: 1.3, + alpha: 0.05, + duration: 2000, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); + this.add - .text(width / 2, height / 2 - 80, 'THE ARENA', { - fontSize: '64px', + .text(width / 2, height / 2 - 120, 'THE ARENA', { + fontSize: '80px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, '#06b6d4', 20, true, true); this.add - .text(width / 2, height / 2 - 20, 'RPS Royale', { - fontSize: '24px', + .text(width / 2, height / 2 - 40, 'RPS ROYALE', { + fontSize: '28px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#94a3b8', + letterSpacing: 8, }) .setOrigin(0.5); - new UIButton(this, width / 2, height / 2 + 80, 'Entrer dans l\'Arène', () => { + const leftAvatar = this.add.image(width / 2 - 220, height / 2 + 40, 'avatar').setScale(0.9); + const rightAvatar = this.add.image(width / 2 + 220, height / 2 + 40, 'avatarAI').setScale(0.9); + this.tweens.add({ targets: leftAvatar, angle: 360, duration: 20000, repeat: -1, ease: 'Linear' }); + this.tweens.add({ targets: rightAvatar, angle: -360, duration: 20000, repeat: -1, ease: 'Linear' }); + + this.add.image(width / 2, height / 2 + 40, 'vs').setScale(1.2); + + new UIButton(this, width / 2, height / 2 + 180, 'Entrer dans\'Arène', () => { + audio.resume(); + audio.playClick(); + audio.startLobbyAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('LobbyScene'); }); }); - - // Simple particle effect for atmosphere - const particles = this.add.particles(0, 0, 'flare', { - x: { min: 0, max: width }, - y: { min: 0, max: height }, - quantity: 1, - frequency: 100, - lifespan: 3000, - alpha: { start: 0.3, end: 0 }, - scale: { start: 0.5, end: 0 }, - tint: [0x06b6d4, 0xa855f7, 0xec4899], - }); - - // Create a simple flare texture procedurally - const graphics = this.make.graphics({ x: 0, y: 0, }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(8, 8, 8); - graphics.generateTexture('flare', 16, 16); - graphics.destroy(); } } diff --git a/apps/web/src/phaser/scenes/LobbyScene.ts b/apps/web/src/phaser/scenes/LobbyScene.ts index 2441094..2a060fc 100644 --- a/apps/web/src/phaser/scenes/LobbyScene.ts +++ b/apps/web/src/phaser/scenes/LobbyScene.ts @@ -1,6 +1,7 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; import { socket } from '@/lib/socket'; +import { audio } from '../audio/AudioManager'; export class LobbyScene extends Phaser.Scene { private tablesText: Phaser.GameObjects.Text[] = []; @@ -13,14 +14,32 @@ export class LobbyScene extends Phaser.Scene { create() { const { width, height } = this.scale; + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + this.add.particles(0, 0, 'spark', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 120, + lifespan: 3000, + alpha: { start: 0.2, end: 0 }, + scale: { start: 0.3, end: 0 }, + tint: [0x06b6d4, 0xa855f7], + }); + this.add - .text(width / 2, 60, 'LOBBY', { - fontSize: '40px', + .text(width / 2, 50, 'SALLES DE JEU', { + fontSize: '44px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, '#22d3ee', 10, true, true); + + this.add.image(width / 2 - 260, 50, 'rock').setScale(0.35); + this.add.image(width / 2, 50, 'paper').setScale(0.35); + this.add.image(width / 2 + 260, 50, 'scissors').setScale(0.35); this.statusText = this.add .text(width / 2, 120, '', { @@ -29,17 +48,21 @@ export class LobbyScene extends Phaser.Scene { }) .setOrigin(0.5); - new UIButton(this, width / 2 - 140, height / 2 + 80, 'Matchmaking (0.01 ETH)', () => { + new UIButton(this, width / 2 - 200, height - 180, 'Matchmaking 0.01 ETH', () => { + audio.playClick(); this.requestMatch('0.01'); - }); + }, 280); - new UIButton(this, width / 2 + 140, height / 2 + 80, 'Matchmaking (0.05 ETH)', () => { + new UIButton(this, width / 2 + 200, height - 180, 'Matchmaking 0.05 ETH', () => { + audio.playClick(); this.requestMatch('0.05'); - }); + }, 280); - new UIButton(this, width / 2, height / 2 + 160, 'Jouer contre l\'IA', () => { + const aiBtn = new UIButton(this, width / 2, height - 90, 'Jouer contre l\'IA', () => { + audio.playClick(); this.requestAIMatch('0.01'); - }); + }, 320, 64); + aiBtn.setLabel('Jouer contre l\'IA'); socket.emit('lobby:join'); @@ -48,6 +71,7 @@ export class LobbyScene extends Phaser.Scene { }); socket.on('match:found', (match) => { + audio.stopAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('ArenaScene', { match }); @@ -58,7 +82,6 @@ export class LobbyScene extends Phaser.Scene { getAddress(): string { const eth = (window as any).ethereum; if (eth?.selectedAddress) return eth.selectedAddress; - // Generate a guest address if no wallet let guest = (window as any).__guestAddress; if (!guest) { guest = '0x' + Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join(''); @@ -80,14 +103,13 @@ export class LobbyScene extends Phaser.Scene { } renderTables(tables: any[]) { - // Clear previous this.tablesText.forEach((t) => t.destroy()); this.tablesText = []; const { width } = this.scale; if (tables.length === 0) { const t = this.add - .text(width / 2, 220, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' }) + .text(width / 2, 200, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' }) .setOrigin(0.5); this.tablesText.push(t); return; @@ -97,7 +119,7 @@ export class LobbyScene extends Phaser.Scene { const t = this.add .text( width / 2, - 200 + i * 40, + 180 + i * 44, `Table #${table.id.slice(0, 6)} — Mise: ${table.betAmount} ETH — Joueurs: ${table.players}/2`, { fontSize: '16px', color: '#cbd5e1' } ) diff --git a/apps/web/src/phaser/scenes/ResultScene.ts b/apps/web/src/phaser/scenes/ResultScene.ts index 8f55682..a641c46 100644 --- a/apps/web/src/phaser/scenes/ResultScene.ts +++ b/apps/web/src/phaser/scenes/ResultScene.ts @@ -1,5 +1,6 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; +import { audio } from '../audio/AudioManager'; interface ResultData { matchId: string; @@ -12,6 +13,7 @@ interface ResultData { } const choiceLabels = ['Pierre', 'Feuille', 'Ciseaux']; +const choiceTextures = ['rock', 'paper', 'scissors']; export class ResultScene extends Phaser.Scene { constructor() { @@ -39,19 +41,89 @@ export class ResultScene extends Phaser.Scene { color = '#ef4444'; } + // Pulsing glow behind result + const glow = this.add.circle(width / 2, height / 2 - 180, 200, parseInt(color.replace('#', '0x')), 0.12); + this.tweens.add({ + targets: glow, + scaleX: 1.4, + scaleY: 1.4, + alpha: 0.04, + duration: 1500, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut', + }); + this.add - .text(width / 2, height / 2 - 140, resultText, { + .text(width / 2, height / 2 - 180, resultText, { fontSize: '72px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color, fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, color, 20, true, true); + + // Animated clash: icons start far apart and smash together + const p1Tex = choiceTextures[data.p1Choice]; + const p2Tex = choiceTextures[data.p2Choice]; + + const leftIcon = this.add.image(-80, height / 2 - 40, p1Tex).setScale(0.2).setAlpha(0); + const rightIcon = this.add.image(width + 80, height / 2 - 40, p2Tex).setScale(0.2).setAlpha(0); + + // Animate icons sliding in from sides and crashing + this.tweens.add({ + targets: leftIcon, + x: width / 2 - 100, + scaleX: 1.4, + scaleY: 1.4, + alpha: 1, + duration: 600, + ease: 'Back.out', + delay: 200, + }); + this.tweens.add({ + targets: rightIcon, + x: width / 2 + 100, + scaleX: 1.4, + scaleY: 1.4, + alpha: 1, + duration: 600, + ease: 'Back.out', + delay: 200, + }); + + // Crash effect after they meet + this.time.delayedCall(800, () => { + this.tweens.add({ + targets: [leftIcon, rightIcon], + scaleX: 1.6, + scaleY: 1.6, + duration: 150, + yoyo: true, + ease: 'Bounce.easeOut', + }); + + // Impact explosion at center + const explosion = this.add.particles(width / 2, height / 2 - 40, 'spark', { + speed: { min: 150, max: 500 }, + angle: { min: 0, max: 360 }, + quantity: 60, + lifespan: 1200, + alpha: { start: 1, end: 0 }, + scale: { start: 1.2, end: 0 }, + tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444, + }); + explosion?.explode(60); + this.time.delayedCall(1300, () => explosion?.destroy()); + }); + + this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.5).setAlpha(0.8); this.add .text( width / 2, - height / 2 - 60, + height / 2 + 60, `Toi: ${choiceLabels[data.isPlayer1 ? data.p1Choice : data.p2Choice]} vs Adversaire: ${ choiceLabels[data.isPlayer1 ? data.p2Choice : data.p1Choice] }`, @@ -63,40 +135,41 @@ export class ResultScene extends Phaser.Scene { this.add .text( width / 2, - height / 2, + height / 2 + 110, isWinner ? `Gain: ${data.payout} ETH` : 'Mise perdue', { fontSize: '28px', color: isWinner ? '#22c55e' : '#ef4444', fontStyle: 'bold' } ) .setOrigin(0.5); } - // Impact particles - const graphics = this.make.graphics({ x: 0, y: 0, }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(4, 4, 4); - graphics.generateTexture('resultSpark', 8, 8); - graphics.destroy(); - - this.add.particles(width / 2, height / 2 - 60, 'resultSpark', { - speed: { min: 100, max: 300 }, + // Secondary lingering particles + const emitter = this.add.particles(width / 2, height / 2 - 40, 'spark', { + speed: { min: 50, max: 200 }, angle: { min: 0, max: 360 }, - quantity: 40, - lifespan: 1500, - alpha: { start: 0.8, end: 0 }, - scale: { start: 0.8, end: 0 }, + quantity: 20, + lifespan: 2000, + alpha: { start: 0.6, end: 0 }, + scale: { start: 0.6, end: 0 }, tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444, }); + emitter?.explode(30); - new UIButton(this, width / 2, height / 2 + 140, 'Retour au Lobby', () => { + new UIButton(this, width / 2, height / 2 + 220, 'Retour au Lobby', () => { + audio.playClick(); + audio.startLobbyAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('LobbyScene'); }); }); - // Camera shake on win + audio.playImpact(); + if (isDraw) audio.playDraw(); + else if (isWinner) audio.playVictory(); + else audio.playDefeat(); + if (isWinner) { - this.cameras.main.shake(500, 0.01); + this.cameras.main.shake(600, 0.015); this.cameras.main.flash(300, 34, 197, 94); } } diff --git a/docker-compose.yml b/docker-compose.yml index b772cd3..0cdc27a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,11 @@ services: build: context: . dockerfile: apps/web/Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.jeu.cosmolan.fr} + NEXT_PUBLIC_CONTRACT_ADDRESS: ${NEXT_PUBLIC_CONTRACT_ADDRESS} + NEXT_PUBLIC_CHAIN_ID: ${NEXT_PUBLIC_CHAIN_ID:-31337} + NEXT_PUBLIC_HARDHAT_RPC_URL: ${NEXT_PUBLIC_HARDHAT_RPC_URL} container_name: rps-web restart: unless-stopped environment: diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md new file mode 100644 index 0000000..57f921d --- /dev/null +++ b/docs/GAME_DESIGN.md @@ -0,0 +1,84 @@ +# Game Design — The Arena RPS Royale + +## Concept + +The Arena RPS Royale est un jeu multijoueur **Pierre-Feuille-Ciseaux** en 2D premium avec paris basés sur la blockchain. Le gameplay repose sur un pattern **commit-reveal** garantissant l'impossibilité de tricher. + +## Mécaniques de jeu + +### 1. Commit (Phase de verrouillage) +- Le joueur choisit son signe (Pierre, Feuille ou Ciseaux) +- Le frontend calcule un hash : `keccak256(choice + nonce)` +- Le joueur envoie le hash au serveur et, si wallet connecté, à la blockchain +- Le choix reste secret : impossible de le deviner à partir du hash + +### 2. Suspense (Phase de tension) +- Quand les deux joueurs ont commité, une phase de suspense démarre +- Zoom caméra, shake, particules intenses +- Compte à rebours de 4 secondes +- Audio : riser montant + heartbeat + +### 3. Reveal (Phase de révélation) +- Les joueurs révèlent leur choix + nonce +- Le serveur (et le smart contract) vérifient que le hash correspond +- Le gagnant est déterminé selon les règles classiques + +### 4. Résultat (Cinématique) +- Les icônes s'animent depuis les côtés et se heurtent au centre +- Explosion de particules colorées selon le résultat +- Affichage du gain ou de la perte +- Audio : fanfare de victoire, son de défaite, ou son de match nul + +## Économie + +- Mise minimale : 0.001 ETH (configurable) +- Frais plateforme : 3% +- Gain : 97% du pot total au gagnant +- Match nul : remboursement 100% + +## Modes de jeu + +### Matchmaking (multijoueur) +- Queue automatique par montant de mise +- Appariement des deux joueurs +- Match enregistré sur la blockchain + +### Contre l'IA (solo) +- Si aucun adversaire n'est disponible, l'IA simule un adversaire +- Délai de réponse aléatoire (1–3s) pour le commit et le reveal +- Pas de transaction blockchain nécessaire +- Adresse IA : `0xA1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1` + +### Sans wallet (invité) +- Le jeu génère automatiquement une adresse guest si MetaMask n'est pas détecté +- L'invité peut jouer en mode IA sans connecter de wallet + +## Expérience visuelle + +### Scènes Phaser + +| Scène | Description | +|-------|-------------| +| BootScene | Écran titre avec particules flottantes, avatars tournants, glow pulsant | +| LobbyScene | Liste des tables, boutons de matchmaking, mode IA | +| ArenaScene | Arène cyberpunk avec grille perspective, colonnes néons, avatars, VS badge | +| ResultScene | Cinématique de clash des icônes avec explosion de particules | + +### Effets visuels +- **Particules** : étincelles flottantes, explosions d'impact +- **Camera** : zoom, shake, flash +- **Tweening** : pulsations, déplacements, apparitions +- **Glow** : halos néons autour des textes et avatars + +### Audio procédural +- **Lobby** : drone basse ambiant +- **Clic** : son triangulaire court +- **Commit** : montée de fréquence carrée +- **Suspense** : riser sawtooth + heartbeat +- **Victoire** : fanfare arpégée + bruit blanc +- **Défaite** : descente de fréquence sawtooth +- **Impact** : bruit blanc filtré exponentiellement + +## Contrôles +- Souris : survol, clic sur les boutons et icônes +- MetaMask : connexion wallet (optionnel) diff --git a/e2e/ai-match.spec.ts b/e2e/ai-match.spec.ts new file mode 100644 index 0000000..85d84e8 --- /dev/null +++ b/e2e/ai-match.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; + +test.describe('The Arena — RPS Royale', () => { + test('play page loads with Phaser canvas', async ({ page }) => { + await page.goto('/play'); + + // Wait for Phaser canvas to appear + const canvas = page.locator('canvas'); + await canvas.waitFor({ state: 'visible', timeout: 10000 }); + + // BootScene title should be visible in the canvas (we can't easily read canvas text, + // but we can verify the canvas has a non-zero size) + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThan(0); + expect(box!.height).toBeGreaterThan(0); + }); + + test('health endpoint responds', async ({ request }) => { + const response = await request.get('http://127.0.0.1:3051/api/health'); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.status).toBe('ok'); + }); + + test('leaderboard page loads', async ({ page }) => { + const response = await page.goto('/leaderboard'); + expect(response?.status()).toBe(200); + }); + + test('home page loads', async ({ page }) => { + const response = await page.goto('/'); + expect(response?.status()).toBe(200); + await expect(page.locator('h1', { hasText: 'The Arena' })).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 257cbb2..905e73c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "pnpm --parallel dev", "lint": "pnpm -r lint", "test": "pnpm -r test", + "test:e2e": "playwright test", "typecheck": "pnpm -r typecheck", "db:migrate": "pnpm --filter server db:migrate", "db:generate": "pnpm --filter server db:generate", @@ -17,6 +18,8 @@ "contract:deploy:sepolia": "pnpm --filter contracts deploy:sepolia" }, "devDependencies": { + "@playwright/test": "^1.60.0", + "playwright": "^1.60.0", "typescript": "^5.6.0" }, "packageManager": "pnpm@9.0.0" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5d3de1d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:3050', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51091d8..c6f7a15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 + playwright: + specifier: ^1.60.0 + version: 1.60.0 typescript: specifier: ^5.6.0 version: 5.9.3 @@ -41,6 +47,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.2 + express-rate-limit: + specifier: ^8.5.2 + version: 8.5.2(express@4.22.2) ioredis: specifier: ^5.4.0 version: 5.10.1 @@ -80,7 +89,7 @@ importers: version: 2.2.4 next: specifier: ^15.1.0 - version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) phaser: specifier: ^3.86.0 version: 3.90.0 @@ -794,6 +803,11 @@ packages: resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==} engines: {node: '>= 12'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@prisma/client@5.22.0': resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} @@ -1636,6 +1650,12 @@ packages: evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.22.2: resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} engines: {node: '>= 0.10.0'} @@ -1742,6 +1762,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1940,6 +1965,10 @@ packages: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2380,6 +2409,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3834,6 +3873,10 @@ snapshots: '@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.2 '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.2 + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@prisma/client@5.22.0(prisma@5.22.0)': optionalDependencies: prisma: 5.22.0 @@ -4823,6 +4866,11 @@ snapshots: md5.js: 1.3.5 safe-buffer: 5.2.1 + express-rate-limit@8.5.2(express@4.22.2): + dependencies: + express: 4.22.2 + ip-address: 10.2.0 + express@4.22.2: dependencies: accepts: 1.3.8 @@ -4970,6 +5018,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5253,6 +5304,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: @@ -5498,7 +5551,7 @@ snapshots: neo-async@2.6.2: {} - next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -5516,6 +5569,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 + '@playwright/test': 1.60.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5628,6 +5682,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.15): diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file