This commit is contained in:
Linrador 2025-09-30 15:30:25 +02:00
parent 074fa4d666
commit 4c94d22709
36 changed files with 1619 additions and 914 deletions

View File

@ -8,8 +8,10 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"cron": "node --import=tsx scripts/cs2-cron-runner.ts",
"test-user": "node --import=tsx scripts/test-run-user.ts"
"test-user": "node --import=tsx scripts/test-run-user.ts",
"worker:dev": "tsx src/worker/index.ts",
"build:worker": "tsc -p tsconfig.worker.json",
"worker:start": "node dist/worker/index.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@ -166,7 +166,7 @@
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
cs2MatchId Int? // die in die JSON geschriebene matchid
cs2MatchId BigInt? @unique // <— wichtig (Postgres lässt mehrere NULLs zu)
exportedAt DateTime? // wann die JSON exportiert wurde
}
@ -184,7 +184,22 @@
createdAt DateTime @default(now())
// ⬇️ NEU: Ban-Snapshot zum Zeitpunkt des Matches
vacBanned Boolean? @default(false)
numberOfVACBans Int? @default(0)
numberOfGameBans Int? @default(0)
daysSinceLastBan Int? @default(0)
communityBanned Boolean? @default(false)
economyBan String? // z.B. "none", "probation", ...
lastBanCheck DateTime?
@@unique([matchId, steamId])
// ⬇️ (optional) hilfreiche Indizes für Filter/Reports:
@@index([vacBanned])
@@index([numberOfVACBans])
@@index([numberOfGameBans])
}
model PlayerStats {

File diff suppressed because one or more lines are too long

View File

@ -198,7 +198,14 @@ exports.Prisma.MatchPlayerScalarFieldEnum = {
steamId: 'steamId',
matchId: 'matchId',
teamId: 'teamId',
createdAt: 'createdAt'
createdAt: 'createdAt',
vacBanned: 'vacBanned',
numberOfVACBans: 'numberOfVACBans',
numberOfGameBans: 'numberOfGameBans',
daysSinceLastBan: 'daysSinceLastBan',
communityBanned: 'communityBanned',
economyBan: 'economyBan',
lastBanCheck: 'lastBanCheck'
};
exports.Prisma.PlayerStatsScalarFieldEnum = {

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-6613cd631161519d0c8efe85070eea9ac4807e6b7e9b83666d4cbc9630bfbbf8",
"name": "prisma-client-b1cec545266f282d46ab4ef2b0fbfe8b0eaa0871cfac86cabf9fc30c7398df36",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@ -166,7 +166,7 @@ model Match {
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
cs2MatchId Int? // die in die JSON geschriebene matchid
cs2MatchId BigInt? @unique // <— wichtig (Postgres lässt mehrere NULLs zu)
exportedAt DateTime? // wann die JSON exportiert wurde
}
@ -184,7 +184,21 @@ model MatchPlayer {
createdAt DateTime @default(now())
// ⬇️ NEU: Ban-Snapshot zum Zeitpunkt des Matches
vacBanned Boolean? @default(false)
numberOfVACBans Int? @default(0)
numberOfGameBans Int? @default(0)
daysSinceLastBan Int? @default(0)
communityBanned Boolean? @default(false)
economyBan String? // z.B. "none", "probation", ...
lastBanCheck DateTime?
@@unique([matchId, steamId])
// ⬇️ (optional) hilfreiche Indizes für Filter/Reports:
@@index([vacBanned])
@@index([numberOfVACBans])
@@index([numberOfGameBans])
}
model PlayerStats {

File diff suppressed because one or more lines are too long

View File

@ -1,31 +0,0 @@
export async function fetchSteamProfile(
steamId: string
): Promise<{ name: string; avatar: string } | null> {
const key = process.env.STEAM_API_KEY;
if (!key) return null;
const url = `https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=${key}&steamids=${steamId}`;
try {
const res = await fetch(url);
const contentType = res.headers.get('content-type') || '';
if (!res.ok || !contentType.includes('application/json')) {
const text = await res.text();
console.warn(`[SteamAPI] ⚠️ Ungültige Antwort für ${steamId} (${res.status}):`, text.slice(0, 200));
return null;
}
const data = await res.json();
const player = data.response?.players?.[0];
if (!player) return null;
return {
name: player.personaname,
avatar: player.avatarfull,
};
} catch (err) {
console.warn(`[SteamAPI] ❌ Fehler für ${steamId}:`, err);
return null;
}
}

View File

@ -1,369 +0,0 @@
import path from 'path';
import fs from 'fs/promises';
import { spawn } from 'child_process';
import { prisma } from '@/lib/prisma';
import { fetchSteamProfile } from './fetchSteamProfile';
import type { Match } from '@/generated/prisma';
import { decodeMatchShareCode } from 'csgo-sharecode';
import { log } from '../../scripts/cs2-cron-runner';
interface PlayerStatsExtended {
name: string;
steamId: string;
team: string;
kills: number;
deaths: number;
assists: number;
flashAssists: number;
mvps: number;
mvpEliminations: number;
mvpDefuse: number;
mvpPlant: number;
knifeKills: number;
zeusKills: number;
wallbangKills: number;
smokeKills: number;
headshots: number;
noScopes: number;
blindKills: number;
totalDamage: number;
utilityDamage: number;
rankOld?: number;
rankNew?: number;
rankChange?: number;
winCount?: number;
aim?: number,
oneK?: number,
twoK?: number,
threeK?: number,
fourK?: number,
fiveK?: number,
}
interface DemoMatchData {
matchId: string;
map: string;
filePath: string;
meta: {
demoDate?: Date;
teamA?: {
name: string;
score: number;
players: PlayerStatsExtended[];
};
teamB?: {
name: string;
score: number;
players: PlayerStatsExtended[];
};
winnerTeam?: string;
roundCount?: number;
roundHistory?: { round: number; winner: string; winReason: string }[];
};
}
export async function parseAndStoreDemo(
demoPath: string,
steamId: string,
shareCode: string
): Promise<Match | null> {
const parsed = await parseDemoViaGo(demoPath, shareCode);
if (!parsed) return null;
let actualDemoPath = demoPath;
if (parsed.map && parsed.map !== 'unknownmap' && demoPath.includes('unknownmap')) {
const oldName = path.basename(demoPath);
const newName = oldName.replace('unknownmap', parsed.map);
const oldPath = path.dirname(demoPath);
const newPath = path.join(oldPath, newName);
await fs.rename(demoPath, newPath);
actualDemoPath = newPath;
const jsonOldPath = path.join(oldPath, oldName.replace(/\.dem$/i, '.json'));
const jsonNewPath = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOldPath, jsonNewPath);
}
try {
const jsonPath = actualDemoPath.replace(/\.dem$/i, '.json');
const jsonContent = await fs.readFile(jsonPath, 'utf-8');
const jsonData = JSON.parse(jsonContent);
if (typeof jsonData?.matchtime === 'number') {
parsed.meta.demoDate = new Date(jsonData.matchtime * 1000);
}
} catch (err) {
log(`[${steamId}] ⚠️ JSON-Datei nicht gefunden oder ungültig. demoDate bleibt unverändert.`, 'warn');
}
const relativePath = path.relative(process.cwd(), actualDemoPath);
const existing = await prisma.match.findUnique({
where: { id: parsed.matchId },
});
if (existing) return null;
const teamAIds: string[] = [];
const teamBIds: string[] = [];
const allPlayers = [
...(parsed.meta.teamA?.players || []),
...(parsed.meta.teamB?.players || []),
];
for (const player of allPlayers) {
let playerUser = await prisma.user.findUnique({
where: { steamId: player.steamId },
});
let steamProfile = null;
if (!playerUser?.name || !playerUser?.avatar) {
steamProfile = await fetchSteamProfile(player.steamId).catch(() => null);
await delay(5000);
}
const isPremier = path.basename(actualDemoPath).toLowerCase().endsWith('_premier.dem');
const updatedFields: Partial<{ name: string; avatar: string; premierRank: number }> = {};
if (!playerUser) {
await prisma.user.create({
data: {
steamId: player.steamId,
name: steamProfile?.name ?? player.name,
avatar: steamProfile?.avatar ?? undefined,
premierRank: isPremier ? player.rankNew ?? undefined : undefined,
},
});
} else {
if (steamProfile?.name && playerUser.name !== steamProfile.name) updatedFields.name = steamProfile.name;
if (steamProfile?.avatar && playerUser.avatar !== steamProfile.avatar) updatedFields.avatar = steamProfile.avatar;
if (Object.keys(updatedFields).length > 0) {
await prisma.user.update({
where: { steamId: player.steamId },
data: updatedFields,
});
}
}
if (parsed.meta.teamA?.players.some(p => p.steamId === player.steamId)) {
teamAIds.push(player.steamId);
} else if (parsed.meta.teamB?.players.some(p => p.steamId === player.steamId)) {
teamBIds.push(player.steamId);
}
}
const allRanks = [
...(parsed.meta.teamA?.players || []),
...(parsed.meta.teamB?.players || []),
].map(p => p.rankNew).filter(r => typeof r === 'number');
const inferredMatchType =
allRanks.length > 0 && allRanks.every(r => r >= 1000)
? 'premier'
: 'competitive';
if (inferredMatchType === 'premier' && actualDemoPath.toLowerCase().endsWith('_competitive.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_competitive\.dem$/i, '_premier.dem');
try {
await fs.rename(oldPath, newPath);
actualDemoPath = newPath;
const jsonOldPath = oldPath.replace(/\.dem$/i, '.json');
const jsonNewPath = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOldPath, jsonNewPath);
} catch (err) {
console.warn('⚠️ Fehler beim Umbenennen auf "_premier.dem":', err);
}
} else if (inferredMatchType === 'competitive' && actualDemoPath.toLowerCase().endsWith('_premier.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_premier\.dem$/i, '_competitive.dem');
try {
await fs.rename(oldPath, newPath);
actualDemoPath = newPath;
const jsonOldPath = oldPath.replace(/\.dem$/i, '.json');
const jsonNewPath = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOldPath, jsonNewPath);
} catch (err) {
console.warn('⚠️ Fehler beim Umbenennen auf "_competitive.dem":', err);
}
}
const match = await prisma.match.create({
data: {
id: parsed.matchId,
title: `CS2 Match auf ${parsed.map} am ${parsed.meta.demoDate?.toLocaleDateString('de-DE') ?? 'unbekannt'}`,
map: parsed.map,
filePath: relativePath,
matchType: inferredMatchType,
scoreA: parsed.meta.teamA?.score,
scoreB: parsed.meta.teamB?.score,
winnerTeam: parsed.meta.winnerTeam ?? null,
roundCount: parsed.meta.roundCount ?? null,
roundHistory: parsed.meta.roundHistory ?? undefined,
demoDate: parsed.meta.demoDate ?? null,
teamAUsers: {
connect: teamAIds.map(steamId => ({ steamId })),
},
teamBUsers: {
connect: teamBIds.map(steamId => ({ steamId })),
},
},
});
await prisma.demoFile.create({
data: {
steamId,
matchId: match.id,
fileName: path.basename(actualDemoPath),
filePath: relativePath,
parsed: true,
},
});
for (const player of allPlayers) {
const teamId =
match.teamAId && parsed.meta.teamA?.players.some(p => p.steamId === player.steamId)
? match.teamAId
: match.teamBId && parsed.meta.teamB?.players.some(p => p.steamId === player.steamId)
? match.teamBId
: undefined;
const matchPlayer = await prisma.matchPlayer.create({
data: {
matchId: match.id,
steamId: player.steamId,
teamId,
},
});
await prisma.playerStats.upsert({
where: {
matchId_steamId: {
matchId: match.id,
steamId: player.steamId,
},
},
update: {
kills: player.kills,
deaths: player.deaths,
assists: player.assists,
totalDamage: player.totalDamage,
utilityDamage: player.utilityDamage,
headshotPct: player.kills > 0 ? player.headshots / player.kills : 0,
flashAssists: player.flashAssists,
mvps: player.mvps,
mvpEliminations: player.mvpEliminations,
mvpDefuse: player.mvpDefuse,
mvpPlant: player.mvpPlant,
knifeKills: player.knifeKills,
zeusKills: player.zeusKills,
wallbangKills: player.wallbangKills,
smokeKills: player.smokeKills,
headshots: player.headshots,
noScopes: player.noScopes,
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
aim: player.aim ?? 0,
oneK: player.oneK ?? 0,
twoK: player.twoK ?? 0,
threeK: player.threeK ?? 0,
fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
},
create: {
id: matchPlayer.id,
matchId: match.id,
steamId: player.steamId,
kills: player.kills,
deaths: player.deaths,
assists: player.assists,
totalDamage: player.totalDamage,
utilityDamage: player.utilityDamage,
headshotPct: player.kills > 0 ? player.headshots / player.kills : 0,
flashAssists: player.flashAssists,
mvps: player.mvps,
mvpEliminations: player.mvpEliminations,
mvpDefuse: player.mvpDefuse,
mvpPlant: player.mvpPlant,
knifeKills: player.knifeKills,
zeusKills: player.zeusKills,
wallbangKills: player.wallbangKills,
smokeKills: player.smokeKills,
headshots: player.headshots,
noScopes: player.noScopes,
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
aim: player.aim ?? 0,
oneK: player.oneK ?? 0,
twoK: player.twoK ?? 0,
threeK: player.threeK ?? 0,
fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
},
});
}
return match;
}
async function parseDemoViaGo(filePath: string, shareCode: string): Promise<DemoMatchData | null> {
if (!shareCode) throw new Error('❌ Kein ShareCode für MatchId verfügbar');
return new Promise((resolve) => {
const parserPath = path.resolve(__dirname, '../../../ironie-cs2-parser/parser_cs2-win.exe');
const decoded = decodeMatchShareCode(shareCode);
const matchId = decoded.matchId.toString();
const proc = spawn(parserPath, [filePath, matchId]);
let output = '';
let errorOutput = '';
proc.stdout.on('data', (data) => (output += data));
proc.stderr.on('data', (data) => (errorOutput += data));
proc.on('close', (code) => {
if (code === 0) {
try {
const parsed = JSON.parse(output);
resolve({
matchId,
map: parsed.map,
filePath,
meta: parsed,
});
} catch (err) {
log('[Parser] ❌ JSON Fehler:', 'error');
log(String(err), 'error');
resolve(null);
}
} else {
log(`[Parser] ❌ Prozess fehlgeschlagen mit Code ${code}`, 'error');
if (errorOutput) log(String(errorOutput), 'error');
resolve(null);
}
});
proc.on('error', (err) => {
log('[Parser] ❌ Spawn Fehler:' + err, 'error');
log(String(err), 'error');
resolve(null);
});
});
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -1,256 +0,0 @@
import cron from 'node-cron';
import { prisma } from '../lib/prisma.js';
import { runDownloaderForUser } from './runDownloaderForUser.js';
import { sendServerSSEMessage } from '../lib/sse-server-client.js';
import { decrypt } from '../lib/crypto.js';
import { encodeMatch, decodeMatchShareCode } from 'csgo-sharecode';
import { log } from '../../scripts/cs2-cron-runner.js';
import { getNextShareCodeFromAPI } from './getNextShareCodeFromAPI.js';
import { updatePremierRanksForUser } from './updatePremierRanks';
import fs from 'fs';
import path from 'path';
let isRunning = false;
/**
* Sucht in demos/YYYY-MM-DD nach einer .dem (oder .dem.part), die zu matchId passt.
* Rückgabe: absoluter Pfad oder null.
*/
function findExistingDemoByMatchId(demosRoot: string, matchId: string): string | null {
// match730_<map>_<matchId>_<premier|competitive>.dem[.part]
const re = new RegExp(`^match\\d+_.+_${matchId}_(premier|competitive)\\.dem(\\.part)?$`, 'i');
if (!fs.existsSync(demosRoot)) return null;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(demosRoot, { withFileTypes: true });
} catch {
return null;
}
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
if (dirent.name === 'temp') continue; // temp auslassen
if (!/^\d{4}-\d{2}-\d{2}$/.test(dirent.name)) continue; // nur YYYY-MM-DD
const dayDir = path.join(demosRoot, dirent.name);
let files: string[] = [];
try {
files = fs.readdirSync(dayDir);
} catch {
continue;
}
for (const fname of files) {
if (!fname.endsWith('.dem') && !fname.endsWith('.dem.part')) continue;
if (re.test(fname)) {
return path.join(dayDir, fname);
}
}
}
return null;
}
export function startCS2MatchCron() {
log('🚀 CS2-CronJob Runner gestartet!');
const job = cron.schedule('* * * * * *', async () => {
await runMatchCheck();
});
runMatchCheck(); // direkt beim Start
return job;
}
async function runMatchCheck() {
if (isRunning) return;
isRunning = true;
const users = await prisma.user.findMany({
where: {
authCode: { not: null },
lastKnownShareCode: { not: null },
},
});
for (const user of users) {
const decryptedAuthCode = decrypt(user.authCode!);
const allNewMatches: { id: string }[] = [];
let latestKnownCode = user.lastKnownShareCode!;
let nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
if (nextShareCode === null) {
const isTooOld =
user.lastKnownShareCodeDate &&
new Date().getTime() - user.lastKnownShareCodeDate.getTime() > 30 * 24 * 60 * 60 * 1000;
if (isTooOld) {
const alreadyNotified = await prisma.notification.findFirst({
where: {
steamId: user.steamId,
actionType: 'expired-sharecode',
},
});
if (!alreadyNotified) {
const notification = await prisma.notification.create({
data: {
steamId: user.steamId,
title: 'Austauschcode abgelaufen',
message: 'Dein gespeicherter Austauschcode ist abgelaufen.',
actionType: 'expired-sharecode',
actionData: JSON.stringify({
redirectUrl: '/settings/account',
}),
},
});
await sendServerSSEMessage({
type: 'expired-sharecode',
targetUserIds: [user.steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
});
}
}
// 📌 Trotzdem Premier-Rank aktualisieren
await updatePremierRanksForUser(user.steamId);
continue;
}
while (nextShareCode) {
const matchInfo = decodeMatchShareCode(nextShareCode);
const existingMatch = await prisma.match.findUnique({
where: { id: matchInfo.matchId.toString() },
});
if (existingMatch) {
// Match ist bereits in der DB überspringen
await prisma.user.update({
where: { steamId: user.steamId },
data: { lastKnownShareCode: nextShareCode },
});
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
continue;
}
await prisma.serverRequest.upsert({
where: {
steamId_matchId: {
steamId: user.steamId,
matchId: matchInfo.matchId.toString(),
},
},
update: {},
create: {
steamId: user.steamId,
matchId: matchInfo.matchId.toString(),
reservationId: matchInfo.reservationId,
tvPort: matchInfo.tvPort,
},
});
const shareCode = encodeMatch(matchInfo);
// ⬇️ NEU: im demos-Ordner (YYYY-MM-DD Unterordner) nach existierender Demo suchen
const demosRoot = path.join(process.cwd(), 'demos');
const existingDemoPath = findExistingDemoByMatchId(demosRoot, matchInfo.matchId.toString());
if (existingDemoPath) {
const rel = path.relative(demosRoot, existingDemoPath);
log(`[${user.steamId}] 📁 Match ${matchInfo.matchId} bereits vorhanden: ${rel} übersprungen`);
// Hinweis: Wir überspringen den Download; DB-Eintrag existierte oben noch nicht.
// Beim nächsten Nutzer/Run wird das Match normal erfasst, oder du ergänzt später
// ein "parseExistingDemo(existingDemoPath)" falls gewünscht.
await prisma.user.update({
where: { steamId: user.steamId },
data: { lastKnownShareCode: nextShareCode },
});
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
continue;
}
// kein File vorhanden -> Downloader/Parser anstoßen
const result = await runDownloaderForUser({
...user,
lastKnownShareCode: shareCode,
});
if (result.newMatches.length > 0) {
allNewMatches.push(...result.newMatches);
await prisma.user.update({
where: { steamId: user.steamId },
data: {
lastKnownShareCode: shareCode,
lastKnownShareCodeDate: new Date(),
},
});
await prisma.serverRequest.updateMany({
where: { matchId: matchInfo.matchId.toString() },
data: { processed: true },
});
latestKnownCode = shareCode;
nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
} else {
log(`❌ Parsing fehlgeschlagen für Match ${matchInfo.matchId}`);
await prisma.serverRequest.updateMany({
where: { matchId: matchInfo.matchId.toString() },
data: { failed: true },
});
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
}
await new Promise((r) => setTimeout(r, 1000));
}
if (allNewMatches.length > 0) {
log(`${allNewMatches.length} neue Matches für ${user.steamId}`);
const notification = await prisma.notification.create({
data: {
steamId: user.steamId,
title: 'Neue CS2-Matches geladen',
message: `${allNewMatches.length} neue Matches wurden analysiert.`,
actionType: 'new-cs2-match',
actionData: JSON.stringify({
matchIds: allNewMatches.map((m) => m.id),
}),
},
});
await sendServerSSEMessage({
type: 'new-cs2-match',
targetUserIds: [user.steamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
});
}
// 📌 Premier-Rank wird **immer** aktualisiert auch wenn keine neuen Matches!
await updatePremierRanksForUser(user.steamId);
}
isRunning = false;
}

View File

@ -1,71 +0,0 @@
import fs from 'fs/promises';
import path from 'path';
import { Match, User } from '@/generated/prisma';
import { parseAndStoreDemo } from './parseAndStoreDemo';
import { log } from '../../scripts/cs2-cron-runner.js';
import { prisma } from '@/lib/prisma';
export async function runDownloaderForUser(user: User): Promise<{
newMatches: Match[];
latestShareCode: string | null;
}> {
if (!user.authCode || !user.lastKnownShareCode) {
throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`);
}
const steamId = user.steamId;
const shareCode = user.lastKnownShareCode;
log(`[${user.steamId}] 📥 Lade Demo herunter...`);
// 🎯 Nur HTTP-Modus
const res = await fetch('http://localhost:4000/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId, shareCode }),
});
const data = await res.json();
if (!data.success) {
log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error');
}
const demoPath = data.path;
if (!demoPath) {
log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten Match wird übersprungen`, 'warn');
return { newMatches: [], latestShareCode: shareCode };
}
const filename = path.basename(demoPath);
const matchId = filename.replace(/\.dem$/, '');
const existing = await prisma.match.findUnique({
where: { id: matchId },
});
if (existing) {
log(`[${steamId}] 🔁 Match ${matchId} wurde bereits analysiert übersprungen`, 'info');
return { newMatches: [], latestShareCode: shareCode };
}
log(`[${steamId}] 📂 Analysiere: ${filename}`);
const absolutePath = path.resolve(__dirname, '../../../cs2-demo-downloader', demoPath);
const match = await parseAndStoreDemo(absolutePath, steamId, shareCode);
const newMatches: Match[] = [];
if (match) {
newMatches.push(match);
log(`[${steamId}] ✅ Match gespeichert: ${match.id}`);
} else {
log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn');
}
return {
newMatches,
latestShareCode: shareCode,
};
}

View File

@ -1,36 +0,0 @@
import { prisma } from '@/lib/prisma';
export async function updatePremierRanksForUser(steamId: string): Promise<void> {
const latestPremierMatch = await prisma.match.findFirst({
where: {
matchType: 'premier',
players: { some: { steamId } },
demoDate: { not: null },
},
orderBy: { demoDate: 'desc' },
include: {
players: {
where: { steamId },
include: {
stats: true,
},
},
},
});
if (!latestPremierMatch) return;
const player = latestPremierMatch.players[0];
const stats = player?.stats;
if (!stats || stats.rankNew == null) return;
const user = await prisma.user.findUnique({ where: { steamId } });
if (!user || user.premierRank === stats.rankNew) return; // 🚫 kein Update nötig
await prisma.user.update({
where: { steamId },
data: { premierRank: stats.rankNew },
});
}

View File

@ -1,19 +0,0 @@
import { MatchInformation } from 'csgo-sharecode'
export function validateMatchInfo(info: MatchInformation): {
valid: boolean
error?: string
} {
if (!info) return { valid: false, error: 'MatchInfo ist null oder undefined' }
if (!info.matchId || info.matchId <= 0n)
return { valid: false, error: 'Ungültige matchId' }
if (!info.reservationId || info.reservationId <= 0n)
return { valid: false, error: 'Ungültige reservationId' }
if (!info.tvPort || info.tvPort <= 0)
return { valid: false, error: 'Ungültiger tvPort' }
return { valid: true }
}

View File

@ -12,23 +12,20 @@ export type MapVoteStep = {
}
export type Match = {
/* Basis-Infos ---------------------------------------------------- */
id : string
title : string
demoDate : string
id : string
title : string
demoDate : string | null
description?: string
map : string
matchType : 'premier' | 'competitive' | 'community' | string
roundCount : number
bestOf? : 1 | 3 | 5
matchDate? : string
scoreA? : number | null
scoreB? : number | null
winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */
teamA: Team
teamB: Team
map : string | null
matchType : 'premier' | 'competitive' | 'community' | string
roundCount?: number
bestOf? : 1 | 3 | 5
matchDate?: string | null
scoreA? : number | null
scoreB? : number | null
winnerTeam?: 'CT' | 'T' | 'Draw' | null
teamA?: Team
teamB?: Team
mapVote?: {
status : 'not_started' | 'in_progress' | 'completed' | null
@ -68,7 +65,7 @@ export type MatchPlayer = {
threeK : number
fourK : number
fiveK : number
}
},
}
/* --------------------------------------------------------------- */

View File

@ -1,5 +1,15 @@
// /types/team.ts
export type BanStatus = {
vacBanned?: boolean
numberOfVACBans?: number
numberOfGameBans?: number
communityBanned?: boolean
economyBan?: string | null
daysSinceLastBan?: number | null
lastBanCheck?: string | Date | null
}
export type Player = {
steamId: string
name: string
@ -7,6 +17,7 @@ export type Player = {
location?: string
premierRank?: number
isAdmin?: boolean
banStatus?: BanStatus
}
export type InvitedPlayer = Player & {

View File

@ -0,0 +1,18 @@
export const NODE_ENV = process.env.NODE_ENV ?? 'development';
export const STEAM_API_KEY =
process.env.STEAM_WEB_API_KEY ??
process.env.STEAM_API_KEY ??
'';
export const STEAM_TIMEOUT_MS = Number(process.env.STEAM_TIMEOUT_MS ?? 15000);
export const STEAM_BATCH_DELAY_MS = Number(process.env.STEAM_BATCH_DELAY_MS ?? 250);
export const CS2_DOWNLOADER_URL =
process.env.CS2_DOWNLOADER_URL ?? 'http://localhost:4000';
export const CS2_INTERNAL_API_URL =
process.env.CS2_INTERNAL_API_URL ?? 'http://localhost:3000';
export const DEMOS_ROOT =
process.env.DEMOS_ROOT ?? 'demos';

7
src/worker/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { startCS2MatchCron } from './jobs/matchScannerCron';
startCS2MatchCron();
// optional: graceful shutdown
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));

View File

@ -1,29 +1,22 @@
import { CS2_INTERNAL_API_URL } from '../../config';
export async function getNextShareCodeFromAPI(
steamId: string,
authCode: string,
currentCode: string
): Promise<string | null> {
try {
const res = await fetch(`http://localhost:3000/api/cs2/getNextCode`, {
const res = await fetch(`${CS2_INTERNAL_API_URL}/api/cs2/getNextCode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId, authCode, currentCode }),
});
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return null;
}
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/json')) return null;
const data = await res.json();
if (!data?.valid || !data?.nextCode || data?.nextCode === 'n/a') {
return null;
}
if (!data?.valid || !data?.nextCode || data?.nextCode === 'n/a') return null;
return data.nextCode;
} catch (err) {
console.error(`❌ Fehler beim Abrufen des nächsten ShareCodes für ${steamId}:`, err);

View File

@ -0,0 +1,33 @@
import { SteamHttp } from './steamClient';
import { STEAM_BATCH_DELAY_MS } from '../../config';
export type SteamBanInfo = {
SteamId: string;
CommunityBanned: boolean;
VACBanned: boolean;
NumberOfVACBans: number;
DaysSinceLastBan: number;
NumberOfGameBans: number;
EconomyBan: string;
};
const http = new SteamHttp();
const chunk = <T,>(arr: T[], size: number) =>
Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size));
export async function getPlayerBans(steamIds: string[]): Promise<Map<string, SteamBanInfo>> {
const out = new Map<string, SteamBanInfo>();
const uniq = [...new Set(steamIds)].filter(Boolean);
const batches = chunk(uniq, 100);
for (const ids of batches) {
const { players } = await http.get<{ players: SteamBanInfo[] }>(
'/ISteamUser/GetPlayerBans/v1/',
{ steamids: ids.join(',') }
);
for (const p of players ?? []) out.set(p.SteamId, p);
if (batches.length > 1) await new Promise(r => setTimeout(r, STEAM_BATCH_DELAY_MS));
}
return out;
}

View File

@ -0,0 +1,30 @@
import fetch from 'node-fetch';
import { STEAM_API_KEY, STEAM_TIMEOUT_MS } from '../../config';
export class SteamHttp {
private base = 'https://api.steampowered.com';
async get<T>(path: string, params: Record<string, string | number>): Promise<T> {
if (!STEAM_API_KEY) throw new Error('STEAM_API_KEY fehlt');
const url = new URL(this.base + path);
url.searchParams.set('key', STEAM_API_KEY);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
let lastErr: any;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(url.toString(), { timeout: STEAM_TIMEOUT_MS as any });
const ct = res.headers.get('content-type') || '';
if (!res.ok || !ct.includes('application/json')) {
const text = await res.text().catch(() => '');
throw new Error(`Steam ${res.status}: ${text.slice(0, 200)}`);
}
return res.json() as Promise<T>;
} catch (e) {
lastErr = e;
await new Promise(r => setTimeout(r, 300));
}
}
throw lastErr;
}
}

View File

@ -0,0 +1,17 @@
import { SteamHttp } from './steamClient';
const http = new SteamHttp();
export async function getPlayerSummaries(steamIds: string[]) {
if (!steamIds.length) return [];
const { response } = await http.get<{ response: { players: any[] } }>(
'/ISteamUser/GetPlayerSummaries/v2/',
{ steamids: steamIds.join(',') }
);
return response.players ?? [];
}
export async function fetchSteamProfile(steamId: string): Promise<{ name: string; avatar: string } | null> {
const players = await getPlayerSummaries([steamId]);
const p = players[0];
return p ? { name: p.personaname, avatar: p.avatarfull } : null;
}

View File

@ -0,0 +1,34 @@
// /src/worker/jobs/processUserMatches.ts
import cron from 'node-cron';
import { prisma } from "@/lib/prisma";
import { decrypt } from '@/lib/crypto';
import { processUserMatches } from '../tasks/processUserMatchesTask';
import { log } from '../lib/logger';
let running = false;
export function startCS2MatchCron() {
log.info('🚀 CS2-CronJob Runner gestartet!');
const job = cron.schedule('* * * * * *', async () => {
if (running) return;
running = true;
try { await runMatchCheck(); }
finally { running = false; }
});
// initial
//runMatchCheck().catch(e => log.error(e));
return job;
}
async function runMatchCheck() {
const users = await prisma.user.findMany({
where: { authCode: { not: null }, lastKnownShareCode: { not: null } },
});
for (const user of users) {
const auth = decrypt(user.authCode!);
await processUserMatches(user.steamId, auth, user.lastKnownShareCode!);
}
}

27
src/worker/lib/fsx.ts Normal file
View File

@ -0,0 +1,27 @@
import fs from 'fs';
import path from 'path';
import { DEMOS_ROOT } from '../config';
export function findExistingDemoByMatchId(matchId: string, demosRoot = DEMOS_ROOT): string | null {
const re = new RegExp(`^match\\d+_.+_${matchId}_(premier|competitive)\\.dem(\\.part)?$`, 'i');
if (!fs.existsSync(demosRoot)) return null;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(demosRoot, { withFileTypes: true }); } catch { return null; }
for (const dirent of entries) {
if (!dirent.isDirectory()) continue;
if (dirent.name === 'temp') continue;
if (!/^\d{4}-\d{2}-\d{2}$/.test(dirent.name)) continue;
const dayDir = path.join(demosRoot, dirent.name);
let files: string[] = [];
try { files = fs.readdirSync(dayDir); } catch { continue; }
for (const fname of files) {
if (!fname.endsWith('.dem') && !fname.endsWith('.dem.part')) continue;
if (re.test(fname)) return path.join(dayDir, fname);
}
}
return null;
}

8
src/worker/lib/logger.ts Normal file
View File

@ -0,0 +1,8 @@
function ts() { return new Date().toISOString(); }
export const log = {
debug: (msg: any, ...rest: any[]) => console.debug(`[${ts()}] [DEBUG]`, msg, ...rest),
info: (msg: any, ...rest: any[]) => console.info (`[${ts()}] [INFO ]`, msg, ...rest),
warn: (msg: any, ...rest: any[]) => console.warn (`[${ts()}] [WARN ]`, msg, ...rest),
error: (msg: any, ...rest: any[]) => console.error(`[${ts()}] [ERROR]`, msg, ...rest),
};

View File

@ -0,0 +1,41 @@
import path from 'path';
import { spawn } from 'child_process';
import { decodeMatchShareCode } from 'csgo-sharecode';
import { log } from '../lib/logger';
export async function parseDemoViaGo(filePath: string, shareCode: string): Promise<any | null> {
if (!shareCode) throw new Error('❌ Kein ShareCode für MatchId verfügbar');
const parserPath = path.resolve(__dirname, '../../../../ironie-cs2-parser/parser_cs2-win.exe');
const decoded = decodeMatchShareCode(shareCode);
const matchId = decoded.matchId.toString();
return new Promise((resolve) => {
const proc = spawn(parserPath, [filePath, matchId]);
let output = '', errorOutput = '';
proc.stdout.on('data', d => (output += d));
proc.stderr.on('data', d => (errorOutput += d));
proc.on('close', (code) => {
if (code === 0) {
try {
const parsed = JSON.parse(output);
resolve({ matchId, map: parsed.map, filePath, meta: parsed });
} catch (err) {
log.error('[Parser] ❌ JSON Fehler:', err);
resolve(null);
}
} else {
log.error(`[Parser] ❌ Prozess fehlgeschlagen mit Code ${code}`);
if (errorOutput) log.error(String(errorOutput));
resolve(null);
}
});
proc.on('error', (err) => {
log.error('[Parser] ❌ Spawn Fehler:' + err);
resolve(null);
});
});
}

View File

@ -0,0 +1,100 @@
// /src/worker/queries/getMatchById.ts
import { prisma } from '@/lib/prisma'
import type { Match as MatchDTO } from '@/types/match' // dein Frontend-Match-Typ
import type { Player, Team as TeamDTO } from '@/types/team'
export async function getMatchByIdDTO(matchId: string): Promise<MatchDTO | null> {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
players: {
include: {
user: true,
stats: true,
team: true,
match: { select: { teamAId: true, teamBId: true } },
},
},
mapVote: {
include: { steps: true },
},
},
})
if (!match) return null
// Hilfsfunktion: Prisma -> Player DTO inkl. BanStatus
const toPlayer = (mp: typeof match.players[number]): Player => ({
steamId: mp.user.steamId,
name: mp.user.name ?? 'Unbekannt',
avatar: mp.user.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
location: mp.user.location ?? undefined,
premierRank: mp.user.premierRank ?? undefined,
isAdmin: mp.user.isAdmin ?? false,
banStatus: {
vacBanned: mp.vacBanned ?? false,
numberOfVACBans: mp.numberOfVACBans ?? 0,
numberOfGameBans: mp.numberOfGameBans ?? 0,
communityBanned: mp.communityBanned ?? false,
economyBan: mp.economyBan ?? null,
daysSinceLastBan: mp.daysSinceLastBan ?? null,
lastBanCheck: mp.lastBanCheck ?? null,
},
})
// Spieler je Team aufteilen
const teamAPlayers = match.players
.filter(p => p.teamId && p.teamId === match.teamAId)
.map(toPlayer)
const teamBPlayers = match.players
.filter(p => p.teamId && p.teamId === match.teamBId)
.map(toPlayer)
const toTeam = (
t: typeof match.teamA, // kann null/undefined sein
players: Player[] // die Spieler, die zu diesem Team gehören
): TeamDTO | undefined => {
if (!t) return undefined; // <-- statt null/&&
return {
id: t.id,
name: t.name,
logo: t.logo,
leader: t.leader ? {
steamId: t.leader.steamId,
name: t.leader.name ?? 'Unbekannt',
avatar: t.leader.avatar ?? '',
premierRank: t.leader.premierRank ?? undefined,
isAdmin: t.leader.isAdmin ?? false,
} : undefined,
activePlayers: players,
inactivePlayers: [],
invitedPlayers: [],
};
};
const dto: MatchDTO = {
id: match.id,
title: match.title,
matchType: match.matchType as any,
map: match.map,
scoreA: match.scoreA ?? null,
scoreB: match.scoreB ?? null,
roundCount: match.roundCount ?? undefined,
roundHistory: match.roundHistory as any,
demoDate: match.demoDate?.toISOString() ?? null,
matchDate: match.matchDate?.toISOString() ?? null,
teamA: toTeam(match.teamA) as any,
teamB: toTeam(match.teamB) as any,
mapVote: match.mapVote ? {
...match.mapVote,
// steps etc. wenn dein Frontend-Typ das braucht
} as any : undefined,
// falls dein Match-DTO Spieler explizit führt:
playersA: teamAPlayers as any,
playersB: teamBPlayers as any,
}
return dto
}

View File

@ -0,0 +1,64 @@
// /src/worker/services/downloaderService.ts
import path from 'path';
import { Match, User } from '@/generated/prisma';
import { parseAndStoreDemoTask } from '../tasks/parseAndStoreDemoTask';
import { log } from '../lib/logger';
import { prisma } from "@/lib/prisma";
import { CS2_DOWNLOADER_URL } from '../config';
export async function runDownloaderForUser(user: User): Promise<{ newMatches: Match[]; latestShareCode: string | null; }> {
if (!user.authCode || !user.lastKnownShareCode) {
throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`);
}
const steamId = user.steamId;
const shareCode = user.lastKnownShareCode;
log.info(`[${steamId}] 📥 Lade Demo herunter...`);
const res = await fetch(`${CS2_DOWNLOADER_URL}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ steamId, shareCode }),
});
const data = await res.json().catch(() => ({} as any));
if (!data.success) {
log.error(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`);
}
const demoPath: string | undefined = data.path;
if (!demoPath) {
log.warn(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten Match wird übersprungen`);
return { newMatches: [], latestShareCode: shareCode };
}
const filename = path.basename(demoPath);
let cs2Id: bigint | null = null;
try { cs2Id = BigInt(filename.replace(/\.dem$/, '')); } catch {}
if (cs2Id) {
const existing = await prisma.match.findFirst({ where: { cs2MatchId: cs2Id } });
if (existing) {
log.info(`[${steamId}] 🔁 Match ${cs2Id} wurde bereits analysiert übersprungen`);
return { newMatches: [], latestShareCode: shareCode };
}
}
log.info(`[${steamId}] 📂 Analysiere: ${filename}`);
// Pfad wie in deinem bisherigen Setup
const absolutePath = path.resolve(__dirname, '../../../cs2-demo-downloader', demoPath);
const match = await parseAndStoreDemoTask(absolutePath, steamId, shareCode);
const newMatches: Match[] = [];
if (match) {
newMatches.push(match);
log.info(`[${steamId}] ✅ Match gespeichert: ${match.id}`);
} else {
log.warn(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`);
}
return { newMatches, latestShareCode: shareCode };
}

View File

@ -0,0 +1,30 @@
import { prisma } from "@/lib/prisma";
import { sendServerSSEMessage } from '@/lib/sse-server-client';
export async function notify(userSteamId: string, payload: {
title: string;
message: string;
actionType?: string;
actionData?: any;
typeForSSE: string;
}) {
const notification = await prisma.notification.create({
data: {
steamId: userSteamId,
title: payload.title,
message: payload.message,
actionType: payload.actionType,
actionData: payload.actionData ? JSON.stringify(payload.actionData) : undefined,
},
});
await sendServerSSEMessage({
type: payload.typeForSSE,
targetUserIds: [userSteamId],
message: notification.message,
id: notification.id,
actionType: notification.actionType ?? undefined,
actionData: notification.actionData ?? undefined,
createdAt: notification.createdAt.toISOString(),
});
}

View File

@ -0,0 +1,49 @@
// /src/worker/services/userService.ts
import { prisma } from "@/lib/prisma";
import { fetchSteamProfile } from "../integrations/steam/users";
const pickDefined = <T extends Record<string, any>>(obj: T) =>
Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
export async function ensureUserWithProfile(
steamId: string,
fallbackName?: string,
isPremierRank?: number
) {
// Aktuellen Datensatz laden (um unnötige Steam-Calls zu sparen)
const existing = await prisma.user.findUnique({ where: { steamId } });
if (existing?.name && existing?.avatar) {
if (typeof isPremierRank === "number" && existing.premierRank !== isPremierRank) {
return prisma.user.update({ where: { steamId }, data: { premierRank: isPremierRank } });
}
return existing;
}
// Nur wenn Infos fehlen, Steam-Profil ziehen
let prof: { name?: string; avatar?: string } | null = null;
try {
prof = await fetchSteamProfile(steamId);
} catch {
// weich fallen lassen
}
// Nur definierte Felder updaten (Prisma ignoriert undefined)
const updateData = pickDefined({
name: prof?.name ?? existing?.name ?? fallbackName,
avatar: prof?.avatar ?? existing?.avatar,
premierRank: typeof isPremierRank === "number" ? isPremierRank : undefined,
});
// 🔒 Atomisch: legt an, wenn nicht vorhanden sonst aktualisiert
return prisma.user.upsert({
where: { steamId },
update: updateData,
create: {
steamId,
// Beim Create sollte ein Name gesetzt sein (kann aber optional bleiben)
name: (updateData as any).name,
avatar: (updateData as any).avatar,
premierRank: (updateData as any).premierRank,
},
});
}

View File

@ -0,0 +1,316 @@
// /src/worker/tasks/parseAndStoreDemoTask.ts
import path from 'path';
import fs from 'fs/promises';
import { prisma } from "@/lib/prisma";
import { parseDemoViaGo } from '../parsers/demoParser';
import { ensureUserWithProfile } from '../services/userService';
import { getPlayerBans } from '../integrations/steam/bans';
import { log } from '../lib/logger';
type PlayerStatsExtended = {
name: string; steamId: string; team: string;
kills: number; deaths: number; assists: number;
flashAssists: number; mvps: number; mvpEliminations: number;
mvpDefuse: number; mvpPlant: number; knifeKills: number; zeusKills: number;
wallbangKills: number; smokeKills: number; headshots: number; noScopes: number;
blindKills: number; totalDamage: number; utilityDamage: number;
rankOld?: number; rankNew?: number; rankChange?: number; winCount?: number;
aim?: number; oneK?: number; twoK?: number; threeK?: number; fourK?: number; fiveK?: number;
};
type DemoMatchData = {
matchId: string; map: string; filePath: string; meta: {
demoDate?: Date;
teamA?: { name: string; score: number; players: PlayerStatsExtended[]; };
teamB?: { name: string; score: number; players: PlayerStatsExtended[]; };
winnerTeam?: string; roundCount?: number;
roundHistory?: { round: number; winner: string; winReason: string }[];
};
};
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
export async function parseAndStoreDemoTask(
demoPath: string,
steamId: string,
shareCode: string
) {
const parsed = await parseDemoViaGo(demoPath, shareCode) as DemoMatchData | null;
if (!parsed) return null;
let actualDemoPath = demoPath;
// unknownmap -> echte map im Dateinamen
if (parsed.map && parsed.map !== 'unknownmap' && demoPath.includes('unknownmap')) {
const oldName = path.basename(demoPath);
const newName = oldName.replace('unknownmap', parsed.map);
const dir = path.dirname(demoPath);
const newPath = path.join(dir, newName);
await fs.rename(demoPath, newPath).catch(() => {});
actualDemoPath = newPath;
const jsonOld = path.join(dir, oldName.replace(/\.dem$/i, '.json'));
const jsonNew = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOld, jsonNew).catch(() => {});
}
// demoDate aus JSON (falls vorhanden)
try {
const jsonPath = actualDemoPath.replace(/\.dem$/i, '.json');
const jsonContent = await fs.readFile(jsonPath, 'utf-8');
const jsonData = JSON.parse(jsonContent);
if (typeof jsonData?.matchtime === 'number') parsed.meta.demoDate = new Date(jsonData.matchtime * 1000);
} catch {
log.warn(`[${steamId}] ⚠️ JSON-Datei nicht gefunden/ungültig. demoDate bleibt unverändert.`);
}
let cs2Id: bigint | null = null
try { cs2Id = BigInt(parsed.matchId); } catch { /* lassen wir null */ }
if (cs2Id) {
const existingByCs2 = await prisma.match.findFirst({ where: { cs2MatchId: cs2Id } });
if (existingByCs2) return null;
}
const teamAIds: string[] = [];
const teamBIds: string[] = [];
const allPlayers: PlayerStatsExtended[] = [
...(parsed.meta.teamA?.players ?? []),
...(parsed.meta.teamB?.players ?? []),
];
// User & evtl. Rank
for (const p of allPlayers) {
const isPremier = path.basename(actualDemoPath).toLowerCase().endsWith('_premier.dem');
const premierRank = isPremier ? (p.rankNew ?? undefined) : undefined;
await ensureUserWithProfile(p.steamId, p.name, premierRank);
if (!premierRank) await delay(200);
if (parsed.meta.teamA?.players.some(x => x.steamId === p.steamId)) teamAIds.push(p.steamId);
else if (parsed.meta.teamB?.players.some(x => x.steamId === p.steamId)) teamBIds.push(p.steamId);
}
// MatchType Heuristik
const rankVals = allPlayers.map(p => p.rankNew).filter((r): r is number => typeof r === 'number');
const inferredMatchType = rankVals.length > 0 && rankVals.every(r => r >= 1000) ? 'premier' : 'competitive';
// Dateiendung angleichen
if (inferredMatchType === 'premier' && actualDemoPath.toLowerCase().endsWith('_competitive.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_competitive\.dem$/i, '_premier.dem');
await fs.rename(oldPath, newPath).catch(() => {});
actualDemoPath = newPath;
const jsonOld = oldPath.replace(/\.dem$/i, '.json');
const jsonNew = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOld, jsonNew).catch(() => {});
} else if (inferredMatchType === 'competitive' && actualDemoPath.toLowerCase().endsWith('_premier.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_premier\.dem$/i, '_competitive.dem');
await fs.rename(oldPath, newPath).catch(() => {});
actualDemoPath = newPath;
const jsonOld = oldPath.replace(/\.dem$/i, '.json');
const jsonNew = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOld, jsonNew).catch(() => {});
}
// Bans (batched)
let bans = new Map<string, any>();
try { if (allPlayers.length) bans = await getPlayerBans(allPlayers.map(p => p.steamId)); }
catch (e) { log.warn(`⚠️ Steam Ban Fetch fehlgeschlagen: ${String(e)}`); }
const relativePath = path.relative(process.cwd(), actualDemoPath);
// Match anlegen
const match = await prisma.match.create({
data: {
title: `CS2 Match auf ${parsed.map} am ${parsed.meta.demoDate?.toLocaleDateString('de-DE') ?? 'unbekannt'}`,
map: parsed.map,
filePath: relativePath,
matchType: inferredMatchType,
scoreA: parsed.meta.teamA?.score,
scoreB: parsed.meta.teamB?.score,
winnerTeam: parsed.meta.winnerTeam ?? null,
roundCount: parsed.meta.roundCount ?? null,
roundHistory: parsed.meta.roundHistory ?? undefined,
demoDate: parsed.meta.demoDate ?? null,
cs2MatchId: cs2Id ?? undefined,
teamAUsers: { connect: teamAIds.map(steamId => ({ steamId })) },
teamBUsers: { connect: teamBIds.map(steamId => ({ steamId })) },
},
});
const baseName = path.basename(actualDemoPath);
const relative = relativePath;
// 1) Gibt es schon ein DemoFile für dieses Match?
const existingByMatch = await prisma.demoFile.findUnique({ where: { matchId: match.id } });
if (existingByMatch) {
// ggf. nur Metadaten aktualisieren (keine Unique-Kollision mehr möglich)
await prisma.demoFile.update({
where: { matchId: match.id },
data: {
steamId,
fileName: baseName, // ⚠ kann immer noch gegen fileName-Unique laufen,
filePath: relative, // deswegen fangen wir das unten zusätzlich ab.
parsed: true,
},
}).catch(async (e: any) => {
// fileName-Kollision → alternativen Dateinamen vergeben
if (e?.code === 'P2002') {
const ext = path.extname(baseName);
const name = path.basename(baseName, ext);
const altName = `${name}-${match.id}${ext}`;
await prisma.demoFile.update({
where: { matchId: match.id },
data: {
steamId,
fileName: altName,
filePath: relative,
parsed: true,
},
});
} else {
throw e;
}
});
} else {
// 2) Keines für dieses Match: gibt es bereits ein DemoFile mit gleichem fileName?
const existingByFile = await prisma.demoFile.findUnique({ where: { fileName: baseName } });
if (existingByFile) {
if (existingByFile.matchId === match.id) {
// identischer Datensatz nur sicherheitshalber aktualisieren
await prisma.demoFile.update({
where: { matchId: match.id },
data: { steamId, filePath: relative, parsed: true },
});
} else {
// anderer Match-Datensatz blockiert den Dateinamen → neuen Dateinamen erzeugen
const ext = path.extname(baseName);
const name = path.basename(baseName, ext);
const altName = `${name}-${match.id}${ext}`;
await prisma.demoFile.create({
data: {
steamId,
matchId: match.id,
fileName: altName,
filePath: relative,
parsed: true,
},
});
}
} else {
// 3) Normalfall: neu anlegen
await prisma.demoFile.create({
data: {
steamId,
matchId: match.id,
fileName: baseName,
filePath: relative,
parsed: true,
},
});
}
}
// MatchPlayer + Stats + Ban Snapshot
for (const player of allPlayers) {
const onA = parsed.meta.teamA?.players.some(p => p.steamId === player.steamId);
const onB = parsed.meta.teamB?.players.some(p => p.steamId === player.steamId);
const teamId = onA ? match.teamAId : onB ? match.teamBId : undefined;
const ban = bans.get(player.steamId);
const matchPlayer = await prisma.matchPlayer.create({
data: {
matchId: match.id,
steamId: player.steamId,
teamId,
// Option A: Ban-Snapshot-Felder am MatchPlayer
vacBanned: ban?.VACBanned ?? false,
numberOfVACBans: ban?.NumberOfVACBans ?? 0,
numberOfGameBans: ban?.NumberOfGameBans ?? 0,
daysSinceLastBan: ban?.DaysSinceLastBan ?? 0,
communityBanned: ban?.CommunityBanned ?? false,
economyBan: ban?.EconomyBan ?? null,
lastBanCheck: ban ? new Date() : null,
},
});
await prisma.playerStats.upsert({
where: { matchId_steamId: { matchId: match.id, steamId: player.steamId } },
update: {
kills: player.kills,
deaths: player.deaths,
assists: player.assists,
totalDamage: player.totalDamage,
utilityDamage: player.utilityDamage,
headshotPct: player.kills > 0 ? player.headshots / player.kills : 0,
flashAssists: player.flashAssists,
mvps: player.mvps,
mvpEliminations: player.mvpEliminations,
mvpDefuse: player.mvpDefuse,
mvpPlant: player.mvpPlant,
knifeKills: player.knifeKills,
zeusKills: player.zeusKills,
wallbangKills: player.wallbangKills,
smokeKills: player.smokeKills,
headshots: player.headshots,
noScopes: player.noScopes,
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
aim: player.aim ?? 0,
oneK: player.oneK ?? 0,
twoK: player.twoK ?? 0,
threeK: player.threeK ?? 0,
fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
},
create: {
id: matchPlayer.id,
matchId: match.id,
steamId: player.steamId,
kills: player.kills,
deaths: player.deaths,
assists: player.assists,
totalDamage: player.totalDamage,
utilityDamage: player.utilityDamage,
headshotPct: player.kills > 0 ? player.headshots / player.kills : 0,
flashAssists: player.flashAssists,
mvps: player.mvps,
mvpEliminations: player.mvpEliminations,
mvpDefuse: player.mvpDefuse,
mvpPlant: player.mvpPlant,
knifeKills: player.knifeKills,
zeusKills: player.zeusKills,
wallbangKills: player.wallbangKills,
smokeKills: player.smokeKills,
headshots: player.headshots,
noScopes: player.noScopes,
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
aim: player.aim ?? 0,
oneK: player.oneK ?? 0,
twoK: player.twoK ?? 0,
threeK: player.threeK ?? 0,
fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
},
});
}
return match;
}

View File

@ -0,0 +1,126 @@
// /src/worker/tasks/processUserMatches.ts
import path from 'path';
import { encodeMatch, decodeMatchShareCode } from 'csgo-sharecode';
import { prisma } from "@/lib/prisma";
import { getNextShareCodeFromAPI } from '../integrations/cs2/sharecode';
import { runDownloaderForUser } from '../services/downloaderService';
import { notify } from '../services/notificationService';
import { findExistingDemoByMatchId } from '../lib/fsx';
import { updatePremierRanksForUser } from './updatePremierRanksTask';
import { log } from '../lib/logger';
import { DEMOS_ROOT } from '../config';
export async function processUserMatches(steamId: string, authCode: string, lastKnownShareCode: string) {
const allNewMatches: { id: string }[] = [];
let latestKnownCode = lastKnownShareCode;
let nextShareCode = await getNextShareCodeFromAPI(steamId, authCode, latestKnownCode);
if (nextShareCode === null) {
const user = await prisma.user.findUnique({ where: { steamId } });
const isTooOld = user?.lastKnownShareCodeDate &&
(Date.now() - user.lastKnownShareCodeDate.getTime() > 30 * 24 * 60 * 60 * 1000);
if (isTooOld) {
const alreadyNotified = await prisma.notification.findFirst({
where: { steamId, actionType: 'expired-sharecode' },
});
if (!alreadyNotified) {
await notify(steamId, {
title: 'Austauschcode abgelaufen',
message: 'Dein gespeicherter Austauschcode ist abgelaufen.',
actionType: 'expired-sharecode',
actionData: { redirectUrl: '/settings/account' },
typeForSSE: 'expired-sharecode',
});
}
}
await updatePremierRanksForUser(steamId);
return;
}
while (nextShareCode) {
const matchInfo = decodeMatchShareCode(nextShareCode);
const existingMatch = await prisma.match.findFirst({
where: { cs2MatchId: matchInfo.matchId }, // matchId ist BigInt aus decodeMatchShareCode
});
if (existingMatch) {
await prisma.user.update({
where: { steamId },
data: { lastKnownShareCode: nextShareCode },
});
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(steamId, authCode, latestKnownCode);
continue;
}
await prisma.serverRequest.upsert({
where: { steamId_matchId: { steamId, matchId: matchInfo.matchId.toString() } },
update: {},
create: {
steamId,
matchId: matchInfo.matchId.toString(),
reservationId: matchInfo.reservationId,
tvPort: matchInfo.tvPort,
},
});
const shareCode = encodeMatch(matchInfo);
const existingDemoPath = findExistingDemoByMatchId(matchInfo.matchId.toString(), DEMOS_ROOT);
if (existingDemoPath) {
const rel = path.relative(DEMOS_ROOT, existingDemoPath);
log.info(`[${steamId}] 📁 Match ${matchInfo.matchId} bereits vorhanden: ${rel} übersprungen`);
await prisma.user.update({ where: { steamId }, data: { lastKnownShareCode: nextShareCode } });
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(steamId, authCode, latestKnownCode);
continue;
}
const user = await prisma.user.findUnique({ where: { steamId } });
const result = await runDownloaderForUser({ ...user!, lastKnownShareCode: shareCode } as any);
if (result.newMatches.length > 0) {
allNewMatches.push(...result.newMatches);
await prisma.user.update({
where: { steamId },
data: { lastKnownShareCode: shareCode, lastKnownShareCodeDate: new Date() },
});
await prisma.serverRequest.updateMany({
where: { matchId: matchInfo.matchId.toString() },
data: { processed: true },
});
latestKnownCode = shareCode;
nextShareCode = await getNextShareCodeFromAPI(steamId, authCode, latestKnownCode);
} else {
log.error(`❌ Parsing fehlgeschlagen für Match ${matchInfo.matchId}`);
await prisma.serverRequest.updateMany({
where: { matchId: matchInfo.matchId.toString() },
data: { failed: true },
});
latestKnownCode = nextShareCode;
nextShareCode = await getNextShareCodeFromAPI(steamId, authCode, latestKnownCode);
}
await new Promise(r => setTimeout(r, 1000));
}
if (allNewMatches.length > 0) {
await notify(steamId, {
title: 'Neue CS2-Matches geladen',
message: `${allNewMatches.length} neue Matches wurden analysiert.`,
actionType: 'new-cs2-match',
actionData: { matchIds: allNewMatches.map(m => m.id) },
typeForSSE: 'new-cs2-match',
});
}
await updatePremierRanksForUser(steamId);
}

View File

@ -0,0 +1,18 @@
import { prisma } from "@/lib/prisma";
export async function updatePremierRanksForUser(steamId: string): Promise<void> {
const latestPremierMatch = await prisma.match.findFirst({
where: { matchType: 'premier', players: { some: { steamId } }, demoDate: { not: null } },
orderBy: { demoDate: 'desc' },
include: { players: { where: { steamId }, include: { stats: true } } },
});
if (!latestPremierMatch) return;
const stats = latestPremierMatch.players[0]?.stats;
if (!stats || stats.rankNew == null) return;
const user = await prisma.user.findUnique({ where: { steamId } });
if (!user || user.premierRank === stats.rankNew) return;
await prisma.user.update({ where: { steamId }, data: { premierRank: stats.rankNew } });
}