updated
This commit is contained in:
parent
074fa4d666
commit
4c94d22709
@ -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",
|
||||
|
||||
@ -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
@ -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 = {
|
||||
|
||||
672
src/generated/prisma/index.d.ts
vendored
672
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-6613cd631161519d0c8efe85070eea9ac4807e6b7e9b83666d4cbc9630bfbbf8",
|
||||
"name": "prisma-client-b1cec545266f282d46ab4ef2b0fbfe8b0eaa0871cfac86cabf9fc30c7398df36",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp25156
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp25156
Normal file
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp26408
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp26408
Normal file
Binary file not shown.
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- */
|
||||
|
||||
@ -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 & {
|
||||
|
||||
18
src/worker/config/index.ts
Normal file
18
src/worker/config/index.ts
Normal 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
7
src/worker/index.ts
Normal 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));
|
||||
@ -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);
|
||||
33
src/worker/integrations/steam/bans.ts
Normal file
33
src/worker/integrations/steam/bans.ts
Normal 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;
|
||||
}
|
||||
30
src/worker/integrations/steam/steamClient.ts
Normal file
30
src/worker/integrations/steam/steamClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/worker/integrations/steam/users.ts
Normal file
17
src/worker/integrations/steam/users.ts
Normal 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;
|
||||
}
|
||||
34
src/worker/jobs/matchScannerCron.ts
Normal file
34
src/worker/jobs/matchScannerCron.ts
Normal 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
27
src/worker/lib/fsx.ts
Normal 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
8
src/worker/lib/logger.ts
Normal 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),
|
||||
};
|
||||
41
src/worker/parsers/demoParser.ts
Normal file
41
src/worker/parsers/demoParser.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
src/worker/queries/getMatchById.ts
Normal file
100
src/worker/queries/getMatchById.ts
Normal 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
|
||||
}
|
||||
64
src/worker/services/downloaderService.ts
Normal file
64
src/worker/services/downloaderService.ts
Normal 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 };
|
||||
}
|
||||
30
src/worker/services/notificationService.ts
Normal file
30
src/worker/services/notificationService.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
49
src/worker/services/userService.ts
Normal file
49
src/worker/services/userService.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
316
src/worker/tasks/parseAndStoreDemoTask.ts
Normal file
316
src/worker/tasks/parseAndStoreDemoTask.ts
Normal 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;
|
||||
}
|
||||
126
src/worker/tasks/processUserMatchesTask.ts
Normal file
126
src/worker/tasks/processUserMatchesTask.ts
Normal 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);
|
||||
}
|
||||
18
src/worker/tasks/updatePremierRanksTask.ts
Normal file
18
src/worker/tasks/updatePremierRanksTask.ts
Normal 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 } });
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user