mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(arcade): migrate backend from NestJS to Hono/Bun
Replace @arcade/backend (NestJS) with @arcade/server (Hono/Bun). Same two endpoints, no auth required (public game generator): - POST /api/games/generate — AI game generation (Gemini, Claude, GPT) - POST /api/games/submit — Community game submission via GitHub PR - GET /health — Health check This removes the last remaining NestJS backend from the monorepo. NestJS is now completely gone — all servers use Hono + Bun. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7bc4db7e63
commit
6e75718cfa
26 changed files with 4662 additions and 2070 deletions
|
|
@ -87,7 +87,6 @@ Always use `text` type for `user_id` columns in all database schemas.
|
|||
|---------|---------|----------|
|
||||
| `@manacore/shared-hono` | Hono auth middleware + helpers | All compute servers (Hono/Bun) |
|
||||
| `@manacore/shared-auth` | Client auth service | Web/Mobile apps |
|
||||
| `@mana-core/nestjs-integration` | Auth + Credits for NestJS | `@arcade/backend` only |
|
||||
|
||||
## Server Integration (Hono/Bun)
|
||||
|
||||
|
|
@ -113,23 +112,7 @@ app.get('/api/v1/data', (c) => {
|
|||
});
|
||||
```
|
||||
|
||||
### NestJS (arcade only)
|
||||
|
||||
`@arcade/backend` still uses NestJS with `@mana-core/nestjs-integration`:
|
||||
|
||||
```typescript
|
||||
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
|
||||
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ApiController {
|
||||
@Get('data')
|
||||
getData(@CurrentUser() user: any) {
|
||||
return this.service.findAll(user.sub);
|
||||
}
|
||||
}
|
||||
```
|
||||
> Note: Arcade's server (`@arcade/server`) does not require auth — game generation and community submission are public endpoints.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **manacore** | Multi-app ecosystem platform | Mobile, Web |
|
||||
| **chat** | AI chat application | Backend, Mobile, Web, Landing |
|
||||
| **picture** | AI image generation | Backend, Mobile, Web, Landing |
|
||||
| **memoro** | AI voice recording & memo management | Backend, Audio-Backend, Mobile, Web, Landing |
|
||||
| **manadeck** | Card/deck management | Backend, Mobile, Web |
|
||||
| **todo** | Task management | Backend, Web, Landing |
|
||||
| **calendar** | Calendar & scheduling | Backend, Web, Landing |
|
||||
|
|
@ -68,7 +69,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
|
||||
| Game | Description | Tech |
|
||||
|------|-------------|------|
|
||||
| **arcade** | AI browser games platform (22+ games) | SvelteKit, NestJS, Gemini/Claude/GPT |
|
||||
| **arcade** | AI browser games platform (22+ games) | SvelteKit, Hono+Bun, Gemini/Claude/GPT |
|
||||
| **voxelava** | Voxel game | SvelteKit |
|
||||
| **whopixels** | Phaser.js pixel game | Phaser, JavaScript |
|
||||
| **worldream** | World exploration game | SvelteKit |
|
||||
|
|
@ -115,6 +116,7 @@ pnpm setup:db:auth # Setup just auth
|
|||
```bash
|
||||
# Start specific project (runs all apps in project)
|
||||
pnpm run manacore:dev
|
||||
pnpm run memoro:dev
|
||||
pnpm run manadeck:dev
|
||||
pnpm run picture:dev
|
||||
pnpm run chat:dev
|
||||
|
|
@ -161,7 +163,7 @@ manacore-monorepo/
|
|||
│ ├── uload/
|
||||
│ └── wisekeep/
|
||||
├── games/ # Game projects
|
||||
│ ├── arcade/ # AI browser games platform (SvelteKit + NestJS)
|
||||
│ ├── arcade/ # AI browser games platform (SvelteKit + Hono/Bun)
|
||||
│ ├── voxelava/ # Voxel game
|
||||
│ ├── whopixels/ # Phaser.js pixel game
|
||||
│ └── worldream/ # World exploration game
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ games/arcade/
|
|||
│ │ ├── games/ # 22 HTML-Spiele
|
||||
│ │ └── screenshots/ # Game-Thumbnails
|
||||
│ ├── web-astro/ # Alte Astro-App (Referenz, zum Löschen)
|
||||
│ └── backend/ # NestJS API (@arcade/backend)
|
||||
│ └── server/ # Hono/Bun Compute Server (@arcade/server)
|
||||
│ └── src/
|
||||
│ ├── game-generator/ # AI-Spielgenerierung (Gemini, Claude, GPT-4)
|
||||
│ ├── game-submission/ # Community-Einreichungen (GitHub API)
|
||||
│ └── health/
|
||||
│ ├── routes/
|
||||
│ │ └── games.ts # AI-Spielgenerierung + Community-Einreichungen
|
||||
│ └── index.ts
|
||||
└── package.json # Root (arcade)
|
||||
```
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ games/arcade/
|
|||
| i18n | svelte-i18n (DE + EN) |
|
||||
| UI | @manacore/shared-ui (PillNav, AuthGate, etc.) |
|
||||
| Theming | @manacore/shared-theme (multi-theme) |
|
||||
| Backend | NestJS (AI-Generierung, Community) |
|
||||
| Server | Hono + Bun (AI-Generierung, Community) |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
|
@ -58,8 +58,8 @@ pnpm arcade:dev
|
|||
# Nur Web (SvelteKit)
|
||||
pnpm dev:arcade:web
|
||||
|
||||
# Nur Backend (NestJS)
|
||||
pnpm dev:arcade:backend
|
||||
# Nur Server (Hono/Bun)
|
||||
pnpm dev:arcade:server
|
||||
|
||||
# Web + Backend zusammen
|
||||
pnpm dev:arcade:app
|
||||
|
|
@ -67,7 +67,7 @@ pnpm dev:arcade:app
|
|||
|
||||
**Ports:**
|
||||
- Web: http://localhost:5210
|
||||
- Backend: http://localhost:3011
|
||||
- Server: http://localhost:3011
|
||||
|
||||
## Local-First Daten
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
nestjsConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...nestjsConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "@arcade/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"openai": "^4.76.0",
|
||||
"@google/genai": "^1.14.0",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { GameGeneratorModule } from './game-generator/game-generator.module';
|
||||
import { GameSubmissionModule } from './game-submission/game-submission.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
HealthModule,
|
||||
GameGeneratorModule,
|
||||
GameSubmissionModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { IsString, IsOptional, IsIn, MinLength, IsNumber } from 'class-validator';
|
||||
|
||||
export class GenerateGameDto {
|
||||
@IsString()
|
||||
@MinLength(10, { message: 'Bitte gib eine Spielbeschreibung mit mindestens 10 Zeichen ein' })
|
||||
description: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['create', 'iterate'])
|
||||
mode?: 'create' | 'iterate' = 'create';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
originalPrompt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
iterationCount?: number = 0;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
model?: string = 'gemini-2.0-flash';
|
||||
}
|
||||
|
||||
export class GenerateGameResponseDto {
|
||||
success: boolean;
|
||||
html: string;
|
||||
metadata: {
|
||||
description: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { GameGeneratorService } from './game-generator.service';
|
||||
import { GenerateGameDto, GenerateGameResponseDto } from './dto/generate-game.dto';
|
||||
|
||||
@Controller('games')
|
||||
export class GameGeneratorController {
|
||||
constructor(private readonly gameGeneratorService: GameGeneratorService) {}
|
||||
|
||||
@Post('generate')
|
||||
async generateGame(@Body() dto: GenerateGameDto): Promise<GenerateGameResponseDto> {
|
||||
return this.gameGeneratorService.generateGame(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GameGeneratorController } from './game-generator.controller';
|
||||
import { GameGeneratorService } from './game-generator.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GameGeneratorController],
|
||||
providers: [GameGeneratorService],
|
||||
})
|
||||
export class GameGeneratorModule {}
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GenerateGameDto, GenerateGameResponseDto } from './dto/generate-game.dto';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { AzureOpenAI } from 'openai';
|
||||
|
||||
type AIProvider = 'google' | 'anthropic' | 'azure';
|
||||
|
||||
interface ModelConfig {
|
||||
provider: AIProvider;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GameGeneratorService {
|
||||
private readonly logger = new Logger(GameGeneratorService.name);
|
||||
|
||||
// Model configurations
|
||||
private readonly modelConfigs: Record<string, ModelConfig> = {
|
||||
// Google Gemini Models
|
||||
'gemini-2.0-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.0-flash',
|
||||
displayName: 'Gemini 2.0 Flash',
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-flash-preview-05-20',
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-pro-preview-05-06',
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
},
|
||||
// Anthropic Claude Models
|
||||
'claude-3.5-haiku': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
displayName: 'Claude 3.5 Haiku',
|
||||
},
|
||||
'claude-3.5-sonnet': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
displayName: 'Claude Sonnet 4',
|
||||
},
|
||||
// Azure OpenAI Models
|
||||
'gpt-4o': { provider: 'azure', modelId: 'gpt-4o', displayName: 'GPT-4o' },
|
||||
'gpt-4o-mini': { provider: 'azure', modelId: 'gpt-4o-mini', displayName: 'GPT-4o Mini' },
|
||||
};
|
||||
|
||||
// AI Clients
|
||||
private googleClient: GoogleGenAI | null = null;
|
||||
private anthropicClient: Anthropic | null = null;
|
||||
private azureClient: AzureOpenAI | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.initializeClients();
|
||||
}
|
||||
|
||||
private initializeClients(): void {
|
||||
// Initialize Google Gemini
|
||||
const googleApiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
|
||||
if (googleApiKey && googleApiKey !== 'your_google_genai_key_here') {
|
||||
this.googleClient = new GoogleGenAI({ apiKey: googleApiKey });
|
||||
this.logger.log('Google Gemini client initialized');
|
||||
}
|
||||
|
||||
// Initialize Anthropic Claude
|
||||
const anthropicApiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
||||
if (anthropicApiKey && anthropicApiKey !== 'your_anthropic_key_here') {
|
||||
this.anthropicClient = new Anthropic({ apiKey: anthropicApiKey });
|
||||
this.logger.log('Anthropic Claude client initialized');
|
||||
}
|
||||
|
||||
// Initialize Azure OpenAI
|
||||
const azureEndpoint = this.configService.get<string>('AZURE_OPENAI_ENDPOINT');
|
||||
const azureApiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY');
|
||||
if (azureEndpoint && azureApiKey && azureApiKey !== 'your_azure_openai_key_here') {
|
||||
this.azureClient = new AzureOpenAI({
|
||||
endpoint: azureEndpoint,
|
||||
apiKey: azureApiKey,
|
||||
apiVersion: '2024-08-01-preview',
|
||||
});
|
||||
this.logger.log('Azure OpenAI client initialized');
|
||||
}
|
||||
}
|
||||
|
||||
async generateGame(dto: GenerateGameDto): Promise<GenerateGameResponseDto> {
|
||||
const model = dto.model || 'gemini-2.0-flash';
|
||||
const config = this.modelConfigs[model];
|
||||
|
||||
if (!config) {
|
||||
this.logger.warn(`Unknown model: ${model}, falling back to gemini-2.0-flash`);
|
||||
return this.generateGame({ ...dto, model: 'gemini-2.0-flash' });
|
||||
}
|
||||
|
||||
// Check if the provider is available
|
||||
const providerAvailable = this.isProviderAvailable(config.provider);
|
||||
if (!providerAvailable) {
|
||||
this.logger.error(`Provider ${config.provider} is not configured`);
|
||||
throw new InternalServerErrorException(
|
||||
`AI provider ${config.provider} is not configured. Please add the API key.`
|
||||
);
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
const prompt = this.createGamePrompt(
|
||||
dto.description.trim(),
|
||||
dto.mode || 'create',
|
||||
dto.originalPrompt,
|
||||
dto.currentCode
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`${dto.mode === 'iterate' ? 'Iterating' : 'Generating'} game with model: ${config.displayName} (${config.provider})`
|
||||
);
|
||||
|
||||
try {
|
||||
let generatedContent: string;
|
||||
|
||||
switch (config.provider) {
|
||||
case 'google':
|
||||
generatedContent = await this.generateWithGoogle(config.modelId, prompt);
|
||||
break;
|
||||
case 'anthropic':
|
||||
generatedContent = await this.generateWithAnthropic(config.modelId, prompt);
|
||||
break;
|
||||
case 'azure':
|
||||
generatedContent = await this.generateWithAzure(config.modelId, prompt);
|
||||
break;
|
||||
default:
|
||||
throw new InternalServerErrorException(`Unknown provider: ${config.provider}`);
|
||||
}
|
||||
|
||||
// Extract HTML from response
|
||||
let html = generatedContent;
|
||||
const htmlMatch = generatedContent.match(/```html\n([\s\S]*?)\n```/);
|
||||
if (htmlMatch) {
|
||||
html = htmlMatch[1];
|
||||
}
|
||||
|
||||
// Validate and sanitize
|
||||
const safeHtml = this.validateAndSanitizeGame(html);
|
||||
|
||||
this.logger.log(`Game generated successfully with ${config.displayName}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
html: safeHtml,
|
||||
metadata: {
|
||||
description: dto.description.trim(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Generation error with ${config.displayName}:`, error);
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to generate game: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isProviderAvailable(provider: AIProvider): boolean {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return this.googleClient !== null;
|
||||
case 'anthropic':
|
||||
return this.anthropicClient !== null;
|
||||
case 'azure':
|
||||
return this.azureClient !== null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateWithGoogle(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.googleClient) {
|
||||
throw new InternalServerErrorException('Google Gemini client not initialized');
|
||||
}
|
||||
|
||||
const response = await this.googleClient.models.generateContent({
|
||||
model: modelId,
|
||||
contents: prompt,
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
const content = response.text;
|
||||
if (!content) {
|
||||
throw new InternalServerErrorException('No content generated by Google Gemini');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async generateWithAnthropic(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.anthropicClient) {
|
||||
throw new InternalServerErrorException('Anthropic Claude client not initialized');
|
||||
}
|
||||
|
||||
const response = await this.anthropicClient.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: 8192,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (!content || content.type !== 'text') {
|
||||
throw new InternalServerErrorException('No content generated by Anthropic Claude');
|
||||
}
|
||||
|
||||
return content.text;
|
||||
}
|
||||
|
||||
private async generateWithAzure(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.azureClient) {
|
||||
throw new InternalServerErrorException('Azure OpenAI client not initialized');
|
||||
}
|
||||
|
||||
const deployment = this.configService.get<string>('AZURE_OPENAI_DEPLOYMENT') || modelId;
|
||||
|
||||
const response = await this.azureClient.chat.completions.create({
|
||||
model: deployment,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
});
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new InternalServerErrorException('No content generated by Azure OpenAI');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
getAvailableModels(): { id: string; name: string; provider: string; available: boolean }[] {
|
||||
return Object.entries(this.modelConfigs).map(([id, config]) => ({
|
||||
id,
|
||||
name: config.displayName,
|
||||
provider: config.provider,
|
||||
available: this.isProviderAvailable(config.provider),
|
||||
}));
|
||||
}
|
||||
|
||||
private createGamePrompt(
|
||||
description: string,
|
||||
mode: 'create' | 'iterate',
|
||||
originalPrompt?: string,
|
||||
currentCode?: string
|
||||
): string {
|
||||
if (mode === 'iterate' && originalPrompt && currentCode) {
|
||||
return `Du bist ein begabter Coder und Gamedesigner.
|
||||
|
||||
Der Nutzer hat ursprünglich folgendes Spiel gewünscht: "${originalPrompt}"
|
||||
|
||||
Jetzt möchte der Nutzer folgende Änderung: "${description}"
|
||||
|
||||
ERSTELLE DAS SPIEL KOMPLETT NEU mit den gewünschten Änderungen. Orientiere dich am ursprünglichen Konzept, aber implementiere die Änderungen vollständig.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Erstelle ein VOLLSTÄNDIGES neues HTML-Dokument
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Implementiere die gewünschten Änderungen vollständig
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier mit den gewünschten Änderungen
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
return `Du bist ein begabter Coder und Gamedesigner. Erstelle ein HTML5-Spiel basierend auf dieser Beschreibung: ${description}
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Verwende einfache Formen (Rechtecke, Kreise, etc.)
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Füge Steuerungshinweise im Spiel ein
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier
|
||||
// PostMessage beim Start senden:
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
private validateAndSanitizeGame(html: string): string {
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new BadRequestException('Invalid HTML content');
|
||||
}
|
||||
|
||||
if (!html.includes('<!DOCTYPE html>')) {
|
||||
throw new BadRequestException('Invalid game HTML structure');
|
||||
}
|
||||
|
||||
// Security sanitization
|
||||
const sanitized = html
|
||||
.replace(/<script[^>]*src=[^>]*>/gi, '')
|
||||
.replace(/<link[^>]*href=[^>]*>/gi, '')
|
||||
.replace(/fetch\s*\(/gi, '// fetch disabled: fetch(')
|
||||
.replace(/XMLHttpRequest/gi, '// XMLHttpRequest disabled')
|
||||
.replace(/eval\s*\(/gi, '// eval disabled: eval(');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { IsString, IsArray, IsOptional, ValidateNested, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class AuthorDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
github?: string;
|
||||
}
|
||||
|
||||
class FileDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
|
||||
class FilesDto {
|
||||
@ValidateNested()
|
||||
@Type(() => FileDto)
|
||||
html: FileDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FileDto)
|
||||
screenshot: FileDto;
|
||||
}
|
||||
|
||||
export class SubmitGameDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
controls: string;
|
||||
|
||||
@IsIn(['Einfach', 'Mittel', 'Schwer'])
|
||||
difficulty: string;
|
||||
|
||||
@IsIn(['Minimal', 'Einfach', 'Mittel', 'Komplex'])
|
||||
complexity: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => AuthorDto)
|
||||
author: AuthorDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FilesDto)
|
||||
files: FilesDto;
|
||||
|
||||
@IsString()
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
export class SubmitGameResponseDto {
|
||||
success: boolean;
|
||||
message: string;
|
||||
prUrl: string;
|
||||
prNumber: number;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { GameSubmissionService } from './game-submission.service';
|
||||
import { SubmitGameDto, SubmitGameResponseDto } from './dto/submit-game.dto';
|
||||
|
||||
@Controller('games')
|
||||
export class GameSubmissionController {
|
||||
constructor(private readonly gameSubmissionService: GameSubmissionService) {}
|
||||
|
||||
@Post('submit')
|
||||
async submitGame(@Body() dto: SubmitGameDto): Promise<SubmitGameResponseDto> {
|
||||
return this.gameSubmissionService.submitGame(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GameSubmissionController } from './game-submission.controller';
|
||||
import { GameSubmissionService } from './game-submission.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GameSubmissionController],
|
||||
providers: [GameSubmissionService],
|
||||
})
|
||||
export class GameSubmissionModule {}
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SubmitGameDto, SubmitGameResponseDto } from './dto/submit-game.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GameSubmissionService {
|
||||
private readonly logger = new Logger(GameSubmissionService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async submitGame(dto: SubmitGameDto): Promise<SubmitGameResponseDto> {
|
||||
const githubToken = this.configService.get<string>('GITHUB_TOKEN');
|
||||
const githubOwner = this.configService.get<string>('GITHUB_OWNER') || 'tillschneider';
|
||||
const githubRepo = this.configService.get<string>('GITHUB_REPO') || 'mana-games';
|
||||
|
||||
if (!githubToken) {
|
||||
this.logger.error('GitHub token not configured');
|
||||
throw new InternalServerErrorException('Server configuration error - GitHub token missing');
|
||||
}
|
||||
|
||||
// Generate safe file names
|
||||
const gameSlug = dto.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const timestamp = Date.now();
|
||||
const branchName = `community-game-${gameSlug}-${timestamp}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Get the default branch
|
||||
this.logger.log(`Fetching repo: ${githubOwner}/${githubRepo}`);
|
||||
const repoResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
const errorBody = await repoResponse.text();
|
||||
this.logger.error('GitHub API Error:', { status: repoResponse.status, body: errorBody });
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to fetch repository info: ${repoResponse.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json();
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
// 2. Get the latest commit SHA from the default branch
|
||||
const refResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs/heads/${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!refResponse.ok) {
|
||||
throw new InternalServerErrorException('Failed to fetch branch info');
|
||||
}
|
||||
|
||||
const refData = await refResponse.json();
|
||||
const baseSha = refData.object.sha;
|
||||
|
||||
// 3. Create a new branch
|
||||
const createBranchResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!createBranchResponse.ok) {
|
||||
throw new InternalServerErrorException('Failed to create branch');
|
||||
}
|
||||
|
||||
// 4. Prepare game data
|
||||
const nextId = String(Date.now());
|
||||
const gameData = {
|
||||
id: nextId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
slug: gameSlug,
|
||||
htmlFile: `/games/${gameSlug}.html`,
|
||||
thumbnail: `/screenshots/${gameSlug}.jpg`,
|
||||
tags: dto.tags,
|
||||
difficulty: dto.difficulty,
|
||||
complexity: dto.complexity,
|
||||
controls: dto.controls,
|
||||
community: true,
|
||||
author: dto.author.name,
|
||||
submittedAt: dto.submittedAt,
|
||||
};
|
||||
|
||||
// 5. Create files
|
||||
const filesToCreate = [
|
||||
{
|
||||
path: `public/games/${gameSlug}.html`,
|
||||
content: dto.files.html.content,
|
||||
encoding: 'utf-8' as const,
|
||||
},
|
||||
{
|
||||
path: `public/screenshots/${gameSlug}.jpg`,
|
||||
content: dto.files.screenshot.content.split(',')[1], // Remove data:image/jpeg;base64,
|
||||
encoding: 'base64' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch existing community games
|
||||
const communityGamesPath = 'src/data/community-games.json';
|
||||
let communityGames: any[] = [];
|
||||
|
||||
try {
|
||||
const existingFileResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${communityGamesPath}?ref=${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (existingFileResponse.ok) {
|
||||
const existingFile = await existingFileResponse.json();
|
||||
const content = Buffer.from(existingFile.content, 'base64').toString('utf-8');
|
||||
communityGames = JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
|
||||
communityGames.push(gameData);
|
||||
|
||||
filesToCreate.push({
|
||||
path: communityGamesPath,
|
||||
content: JSON.stringify(communityGames, null, 2),
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
// Create all files
|
||||
for (const file of filesToCreate) {
|
||||
const fileContent =
|
||||
file.encoding === 'base64' ? file.content : Buffer.from(file.content).toString('base64');
|
||||
|
||||
const createFileResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${file.path}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: `Add community game: ${dto.title}`,
|
||||
content: fileContent,
|
||||
branch: branchName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!createFileResponse.ok) {
|
||||
const error = await createFileResponse.text();
|
||||
this.logger.error(`Failed to create file ${file.path}:`, error);
|
||||
throw new InternalServerErrorException(`Failed to create file ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create pull request
|
||||
const prBody = `## Neues Community-Spiel: ${dto.title}
|
||||
|
||||
### Spiel-Details
|
||||
- **Autor:** ${dto.author.name}${dto.author.github ? ` (@${dto.author.github})` : ''}
|
||||
- **Beschreibung:** ${dto.description}
|
||||
- **Schwierigkeit:** ${dto.difficulty}
|
||||
- **Komplexität:** ${dto.complexity}
|
||||
- **Steuerung:** ${dto.controls}
|
||||
- **Tags:** ${dto.tags.join(', ')}
|
||||
|
||||
### Dateien
|
||||
- HTML: \`public/games/${gameSlug}.html\`
|
||||
- Screenshot: \`public/screenshots/${gameSlug}.jpg\`
|
||||
|
||||
### Checkliste für Review
|
||||
- [ ] Spiel funktioniert einwandfrei
|
||||
- [ ] Keine externen Abhängigkeiten oder Sicherheitsprobleme
|
||||
- [ ] Familienfreundlicher Inhalt
|
||||
- [ ] Screenshot zeigt das Spiel korrekt
|
||||
- [ ] postMessage Integration vorhanden (optional)
|
||||
|
||||
---
|
||||
*Eingereicht am: ${new Date(dto.submittedAt).toLocaleString('de-DE')}*
|
||||
${dto.author.email ? `*Kontakt: ${dto.author.email}*` : ''}`;
|
||||
|
||||
const prResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/pulls`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: `Community: ${dto.title}`,
|
||||
body: prBody,
|
||||
head: branchName,
|
||||
base: defaultBranch,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!prResponse.ok) {
|
||||
const error = await prResponse.text();
|
||||
this.logger.error('Failed to create PR:', error);
|
||||
throw new InternalServerErrorException('Failed to create pull request');
|
||||
}
|
||||
|
||||
const prData = await prResponse.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Game submitted successfully',
|
||||
prUrl: prData.html_url,
|
||||
prNumber: prData.number,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Submission error:', error);
|
||||
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Failed to submit game: ' + (error.message || 'Unknown error')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'mana-games-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:5210', // SvelteKit dev
|
||||
'http://localhost:4321', // Legacy Astro dev
|
||||
'http://localhost:3000', // Alternative dev
|
||||
],
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
credentials: false,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3010;
|
||||
|
||||
// Increase timeout for long-running AI requests (2 minutes)
|
||||
const server = await app.listen(port);
|
||||
server.setTimeout(120000);
|
||||
|
||||
console.log(`Arcade backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
20
games/arcade/apps/server/package.json
Normal file
20
games/arcade/apps/server/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@arcade/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"@google/genai": "^1.14.0",
|
||||
"hono": "^4.7.5",
|
||||
"openai": "^4.76.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
27
games/arcade/apps/server/src/index.ts
Normal file
27
games/arcade/apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { gamesRoutes } from './routes/games';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3011', 10);
|
||||
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
|
||||
app.notFound((c) => c.json({ error: 'Not found' }, 404));
|
||||
|
||||
app.use('*', cors({ origin: CORS_ORIGINS, credentials: false }));
|
||||
|
||||
app.get('/health', (c) =>
|
||||
c.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'arcade-server' })
|
||||
);
|
||||
|
||||
app.route('/api/games', gamesRoutes);
|
||||
|
||||
console.log(`Arcade server running on http://localhost:${PORT}`);
|
||||
|
||||
export default { port: PORT, fetch: app.fetch };
|
||||
432
games/arcade/apps/server/src/routes/games.ts
Normal file
432
games/arcade/apps/server/src/routes/games.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import { Hono } from 'hono';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { AzureOpenAI } from 'openai';
|
||||
|
||||
type AIProvider = 'google' | 'anthropic' | 'azure';
|
||||
|
||||
interface ModelConfig {
|
||||
provider: AIProvider;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const MODEL_CONFIGS: Record<string, ModelConfig> = {
|
||||
'gemini-2.0-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.0-flash',
|
||||
displayName: 'Gemini 2.0 Flash',
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-flash-preview-05-20',
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-pro-preview-05-06',
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
},
|
||||
'claude-3.5-haiku': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
displayName: 'Claude 3.5 Haiku',
|
||||
},
|
||||
'claude-3.5-sonnet': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
displayName: 'Claude Sonnet 4',
|
||||
},
|
||||
'gpt-4o': { provider: 'azure', modelId: 'gpt-4o', displayName: 'GPT-4o' },
|
||||
'gpt-4o-mini': { provider: 'azure', modelId: 'gpt-4o-mini', displayName: 'GPT-4o Mini' },
|
||||
};
|
||||
|
||||
function initClients() {
|
||||
const googleKey = process.env.GOOGLE_GENAI_API_KEY;
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const azureEndpoint = process.env.AZURE_OPENAI_ENDPOINT;
|
||||
const azureKey = process.env.AZURE_OPENAI_API_KEY;
|
||||
|
||||
return {
|
||||
google:
|
||||
googleKey && !googleKey.includes('your_') ? new GoogleGenAI({ apiKey: googleKey }) : null,
|
||||
anthropic:
|
||||
anthropicKey && !anthropicKey.includes('your_')
|
||||
? new Anthropic({ apiKey: anthropicKey })
|
||||
: null,
|
||||
azure:
|
||||
azureEndpoint && azureKey && !azureKey.includes('your_')
|
||||
? new AzureOpenAI({
|
||||
endpoint: azureEndpoint,
|
||||
apiKey: azureKey,
|
||||
apiVersion: '2024-08-01-preview',
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const clients = initClients();
|
||||
|
||||
function createGamePrompt(
|
||||
description: string,
|
||||
mode: 'create' | 'iterate',
|
||||
originalPrompt?: string,
|
||||
currentCode?: string
|
||||
): string {
|
||||
if (mode === 'iterate' && originalPrompt && currentCode) {
|
||||
return `Du bist ein begabter Coder und Gamedesigner.
|
||||
|
||||
Der Nutzer hat ursprünglich folgendes Spiel gewünscht: "${originalPrompt}"
|
||||
|
||||
Jetzt möchte der Nutzer folgende Änderung: "${description}"
|
||||
|
||||
ERSTELLE DAS SPIEL KOMPLETT NEU mit den gewünschten Änderungen. Orientiere dich am ursprünglichen Konzept, aber implementiere die Änderungen vollständig.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Erstelle ein VOLLSTÄNDIGES neues HTML-Dokument
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Implementiere die gewünschten Änderungen vollständig
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier mit den gewünschten Änderungen
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
return `Du bist ein begabter Coder und Gamedesigner. Erstelle ein HTML5-Spiel basierend auf dieser Beschreibung: ${description}
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Verwende einfache Formen (Rechtecke, Kreise, etc.)
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Füge Steuerungshinweise im Spiel ein
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier
|
||||
// PostMessage beim Start senden:
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
function validateAndSanitizeGame(html: string): string {
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new Error('Invalid HTML content');
|
||||
}
|
||||
if (!html.includes('<!DOCTYPE html>')) {
|
||||
throw new Error('Invalid game HTML structure');
|
||||
}
|
||||
return html
|
||||
.replace(/<script[^>]*src=[^>]*>/gi, '')
|
||||
.replace(/<link[^>]*href=[^>]*>/gi, '')
|
||||
.replace(/fetch\s*\(/gi, '// fetch disabled: fetch(')
|
||||
.replace(/XMLHttpRequest/gi, '// XMLHttpRequest disabled')
|
||||
.replace(/eval\s*\(/gi, '// eval disabled: eval(');
|
||||
}
|
||||
|
||||
async function generateWithProvider(config: ModelConfig, prompt: string): Promise<string> {
|
||||
if (config.provider === 'google') {
|
||||
if (!clients.google) throw new Error('Google Gemini not configured');
|
||||
const response = await clients.google.models.generateContent({
|
||||
model: config.modelId,
|
||||
contents: prompt,
|
||||
config: { temperature: 0.7, maxOutputTokens: 8192 },
|
||||
});
|
||||
const content = response.text;
|
||||
if (!content) throw new Error('No content from Google Gemini');
|
||||
return content;
|
||||
}
|
||||
|
||||
if (config.provider === 'anthropic') {
|
||||
if (!clients.anthropic) throw new Error('Anthropic not configured');
|
||||
const response = await clients.anthropic.messages.create({
|
||||
model: config.modelId,
|
||||
max_tokens: 8192,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
const content = response.content[0];
|
||||
if (!content || content.type !== 'text') throw new Error('No content from Anthropic');
|
||||
return content.text;
|
||||
}
|
||||
|
||||
if (config.provider === 'azure') {
|
||||
if (!clients.azure) throw new Error('Azure OpenAI not configured');
|
||||
const deployment = process.env.AZURE_OPENAI_DEPLOYMENT || config.modelId;
|
||||
const response = await clients.azure.chat.completions.create({
|
||||
model: deployment,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
});
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('No content from Azure OpenAI');
|
||||
return content;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown provider: ${config.provider}`);
|
||||
}
|
||||
|
||||
export const gamesRoutes = new Hono();
|
||||
|
||||
gamesRoutes.post('/generate', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
|
||||
const {
|
||||
description,
|
||||
mode = 'create',
|
||||
originalPrompt,
|
||||
currentCode,
|
||||
model = 'gemini-2.0-flash',
|
||||
} = body;
|
||||
|
||||
if (!description || typeof description !== 'string' || description.trim().length < 10) {
|
||||
return c.json({ error: 'Bitte gib eine Spielbeschreibung mit mindestens 10 Zeichen ein' }, 400);
|
||||
}
|
||||
|
||||
const config = MODEL_CONFIGS[model] ?? MODEL_CONFIGS['gemini-2.0-flash'];
|
||||
|
||||
const isAvailable =
|
||||
(config.provider === 'google' && clients.google !== null) ||
|
||||
(config.provider === 'anthropic' && clients.anthropic !== null) ||
|
||||
(config.provider === 'azure' && clients.azure !== null);
|
||||
|
||||
if (!isAvailable) {
|
||||
return c.json({ error: `AI provider ${config.provider} is not configured` }, 500);
|
||||
}
|
||||
|
||||
const prompt = createGamePrompt(description.trim(), mode, originalPrompt, currentCode);
|
||||
|
||||
try {
|
||||
let raw = await generateWithProvider(config, prompt);
|
||||
|
||||
const htmlMatch = raw.match(/```html\n([\s\S]*?)\n```/);
|
||||
if (htmlMatch) raw = htmlMatch[1];
|
||||
|
||||
const html = validateAndSanitizeGame(raw);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
html,
|
||||
metadata: { description: description.trim(), generatedAt: new Date().toISOString() },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return c.json({ error: `Failed to generate game: ${message}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
gamesRoutes.post('/submit', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
|
||||
const { title, description, controls, difficulty, complexity, tags, author, files, submittedAt } =
|
||||
body;
|
||||
|
||||
if (!title || !description || !files?.html?.content || !files?.screenshot?.content) {
|
||||
return c.json({ error: 'Missing required fields' }, 400);
|
||||
}
|
||||
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const githubOwner = process.env.GITHUB_OWNER || 'tillschneider';
|
||||
const githubRepo = process.env.GITHUB_REPO || 'mana-games';
|
||||
|
||||
if (!githubToken || githubToken.includes('your_')) {
|
||||
return c.json({ error: 'Server configuration error - GitHub token missing' }, 500);
|
||||
}
|
||||
|
||||
const gameSlug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const timestamp = Date.now();
|
||||
const branchName = `community-game-${gameSlug}-${timestamp}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
const repoResponse = await fetch(`https://api.github.com/repos/${githubOwner}/${githubRepo}`, {
|
||||
headers,
|
||||
});
|
||||
if (!repoResponse.ok) {
|
||||
return c.json({ error: `Failed to fetch repository info: ${repoResponse.status}` }, 500);
|
||||
}
|
||||
const repoData = (await repoResponse.json()) as { default_branch: string };
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
const refResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs/heads/${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
if (!refResponse.ok) return c.json({ error: 'Failed to fetch branch info' }, 500);
|
||||
const refData = (await refResponse.json()) as { object: { sha: string } };
|
||||
const baseSha = refData.object.sha;
|
||||
|
||||
const createBranchResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ ref: `refs/heads/${branchName}`, sha: baseSha }),
|
||||
}
|
||||
);
|
||||
if (!createBranchResponse.ok) return c.json({ error: 'Failed to create branch' }, 500);
|
||||
|
||||
const gameData = {
|
||||
id: String(timestamp),
|
||||
title,
|
||||
description,
|
||||
slug: gameSlug,
|
||||
htmlFile: `/games/${gameSlug}.html`,
|
||||
thumbnail: `/screenshots/${gameSlug}.jpg`,
|
||||
tags,
|
||||
difficulty,
|
||||
complexity,
|
||||
controls,
|
||||
community: true,
|
||||
author: author.name,
|
||||
submittedAt,
|
||||
};
|
||||
|
||||
const communityGamesPath = 'src/data/community-games.json';
|
||||
let communityGames: unknown[] = [];
|
||||
|
||||
const existingFileResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${communityGamesPath}?ref=${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
if (existingFileResponse.ok) {
|
||||
const existingFile = (await existingFileResponse.json()) as { content: string };
|
||||
const content = Buffer.from(existingFile.content, 'base64').toString('utf-8');
|
||||
communityGames = JSON.parse(content);
|
||||
}
|
||||
communityGames.push(gameData);
|
||||
|
||||
const filesToCreate = [
|
||||
{
|
||||
path: `public/games/${gameSlug}.html`,
|
||||
content: Buffer.from(files.html.content).toString('base64'),
|
||||
},
|
||||
{
|
||||
path: `public/screenshots/${gameSlug}.jpg`,
|
||||
content: files.screenshot.content.split(',')[1],
|
||||
},
|
||||
{
|
||||
path: communityGamesPath,
|
||||
content: Buffer.from(JSON.stringify(communityGames, null, 2)).toString('base64'),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of filesToCreate) {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${file.path}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: `Add community game: ${title}`,
|
||||
content: file.content,
|
||||
branch: branchName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return c.json({ error: `Failed to create file ${file.path}` }, 500);
|
||||
}
|
||||
|
||||
const prBody = `## Neues Community-Spiel: ${title}
|
||||
|
||||
### Spiel-Details
|
||||
- **Autor:** ${author.name}${author.github ? ` (@${author.github})` : ''}
|
||||
- **Beschreibung:** ${description}
|
||||
- **Schwierigkeit:** ${difficulty}
|
||||
- **Komplexität:** ${complexity}
|
||||
- **Steuerung:** ${controls}
|
||||
- **Tags:** ${(tags as string[]).join(', ')}
|
||||
|
||||
### Dateien
|
||||
- HTML: \`public/games/${gameSlug}.html\`
|
||||
- Screenshot: \`public/screenshots/${gameSlug}.jpg\`
|
||||
|
||||
### Checkliste für Review
|
||||
- [ ] Spiel funktioniert einwandfrei
|
||||
- [ ] Keine externen Abhängigkeiten oder Sicherheitsprobleme
|
||||
- [ ] Familienfreundlicher Inhalt
|
||||
- [ ] Screenshot zeigt das Spiel korrekt
|
||||
|
||||
---
|
||||
*Eingereicht am: ${new Date(submittedAt).toLocaleString('de-DE')}*
|
||||
${author.email ? `*Kontakt: ${author.email}*` : ''}`;
|
||||
|
||||
const prResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/pulls`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: `Community: ${title}`,
|
||||
body: prBody,
|
||||
head: branchName,
|
||||
base: defaultBranch,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!prResponse.ok) return c.json({ error: 'Failed to create pull request' }, 500);
|
||||
|
||||
const prData = (await prResponse.json()) as { html_url: string; number: number };
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Game submitted successfully',
|
||||
prUrl: prData.html_url,
|
||||
prNumber: prData.number,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return c.json({ error: `Failed to submit game: ${message}` }, 500);
|
||||
}
|
||||
});
|
||||
15
games/arcade/apps/server/tsconfig.json
Normal file
15
games/arcade/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
12
package.json
12
package.json
|
|
@ -52,6 +52,14 @@
|
|||
"dev:sync": "cd services/mana-sync && JWKS_URL=http://localhost:3001/api/auth/jwks DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_sync ./server",
|
||||
"dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server",
|
||||
"dev:chat:full": "./scripts/setup-databases.sh chat && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:chat:server\" \"pnpm dev:chat:web\"",
|
||||
"memoro:dev": "turbo run dev --filter=memoro...",
|
||||
"dev:memoro:web": "pnpm --filter @memoro/web dev",
|
||||
"dev:memoro:mobile": "pnpm --filter @memoro/mobile start",
|
||||
"dev:memoro:landing": "pnpm --filter @memoro/landing dev",
|
||||
"dev:memoro:backend": "pnpm --filter @memoro/backend start:dev",
|
||||
"dev:memoro:audio-backend": "pnpm --filter @memoro/audio-backend start:dev",
|
||||
"dev:memoro:app": "concurrently -n backend,web -c yellow,cyan \"pnpm dev:memoro:backend\" \"pnpm dev:memoro:web\"",
|
||||
"dev:memoro:full": "concurrently -n auth,sync,backend,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:memoro:backend\" \"pnpm dev:memoro:web\"",
|
||||
"zitare:dev": "turbo run dev --filter=zitare...",
|
||||
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
|
||||
"dev:zitare:web": "pnpm --filter @zitare/web dev",
|
||||
|
|
@ -136,8 +144,8 @@
|
|||
"dev:voxel-lava:web": "pnpm --filter @voxel-lava/web dev",
|
||||
"arcade:dev": "turbo run dev --filter=arcade...",
|
||||
"dev:arcade:web": "pnpm --filter @arcade/web dev",
|
||||
"dev:arcade:backend": "pnpm --filter @arcade/backend dev",
|
||||
"dev:arcade:app": "turbo run dev --filter=@arcade/web --filter=@arcade/backend",
|
||||
"dev:arcade:server": "pnpm --filter @arcade/server dev",
|
||||
"dev:arcade:app": "turbo run dev --filter=@arcade/web --filter=@arcade/server",
|
||||
"figgos:dev": "turbo run dev --filter=figgos...",
|
||||
"dev:figgos:mobile": "pnpm --filter @figgos/mobile dev",
|
||||
"dev:figgos:web": "pnpm --filter @figgos/web dev",
|
||||
|
|
|
|||
5275
pnpm-lock.yaml
generated
5275
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -282,9 +282,9 @@ const APP_CONFIGS = [
|
|||
},
|
||||
},
|
||||
|
||||
// Arcade Backend (NestJS)
|
||||
// Arcade Server (Hono/Bun)
|
||||
{
|
||||
path: 'games/arcade/apps/backend/.env',
|
||||
path: 'games/arcade/apps/server/.env',
|
||||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.MANA_GAMES_BACKEND_PORT || '3011',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue