updated
This commit is contained in:
parent
074fa4d666
commit
4c94d22709
@ -8,8 +8,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@ -166,7 +166,7 @@
|
|||||||
|
|
||||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
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
|
exportedAt DateTime? // wann die JSON exportiert wurde
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +184,22 @@
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
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])
|
@@unique([matchId, steamId])
|
||||||
|
|
||||||
|
// ⬇️ (optional) hilfreiche Indizes für Filter/Reports:
|
||||||
|
@@index([vacBanned])
|
||||||
|
@@index([numberOfVACBans])
|
||||||
|
@@index([numberOfGameBans])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PlayerStats {
|
model PlayerStats {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -198,7 +198,14 @@ exports.Prisma.MatchPlayerScalarFieldEnum = {
|
|||||||
steamId: 'steamId',
|
steamId: 'steamId',
|
||||||
matchId: 'matchId',
|
matchId: 'matchId',
|
||||||
teamId: 'teamId',
|
teamId: 'teamId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt',
|
||||||
|
vacBanned: 'vacBanned',
|
||||||
|
numberOfVACBans: 'numberOfVACBans',
|
||||||
|
numberOfGameBans: 'numberOfGameBans',
|
||||||
|
daysSinceLastBan: 'daysSinceLastBan',
|
||||||
|
communityBanned: 'communityBanned',
|
||||||
|
economyBan: 'economyBan',
|
||||||
|
lastBanCheck: 'lastBanCheck'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.PlayerStatsScalarFieldEnum = {
|
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",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "default.js",
|
"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")
|
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
|
exportedAt DateTime? // wann die JSON exportiert wurde
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,7 +184,21 @@ model MatchPlayer {
|
|||||||
|
|
||||||
createdAt DateTime @default(now())
|
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])
|
@@unique([matchId, steamId])
|
||||||
|
// ⬇️ (optional) hilfreiche Indizes für Filter/Reports:
|
||||||
|
@@index([vacBanned])
|
||||||
|
@@index([numberOfVACBans])
|
||||||
|
@@index([numberOfGameBans])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PlayerStats {
|
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 = {
|
export type Match = {
|
||||||
/* Basis-Infos ---------------------------------------------------- */
|
|
||||||
id : string
|
id : string
|
||||||
title : string
|
title : string
|
||||||
demoDate : string
|
demoDate : string | null
|
||||||
description?: string
|
description?: string
|
||||||
map : string
|
map : string | null
|
||||||
matchType : 'premier' | 'competitive' | 'community' | string
|
matchType : 'premier' | 'competitive' | 'community' | string
|
||||||
roundCount : number
|
roundCount?: number
|
||||||
bestOf? : 1 | 3 | 5
|
bestOf? : 1 | 3 | 5
|
||||||
matchDate? : string
|
matchDate?: string | null
|
||||||
scoreA? : number | null
|
scoreA? : number | null
|
||||||
scoreB? : number | null
|
scoreB? : number | null
|
||||||
winnerTeam? : 'CT' | 'T' | 'Draw' | null
|
winnerTeam?: 'CT' | 'T' | 'Draw' | null
|
||||||
|
teamA?: Team
|
||||||
/* Teams ---------------------------------------------------------- */
|
teamB?: Team
|
||||||
teamA: Team
|
|
||||||
teamB: Team
|
|
||||||
|
|
||||||
mapVote?: {
|
mapVote?: {
|
||||||
status : 'not_started' | 'in_progress' | 'completed' | null
|
status : 'not_started' | 'in_progress' | 'completed' | null
|
||||||
@ -68,7 +65,7 @@ export type MatchPlayer = {
|
|||||||
threeK : number
|
threeK : number
|
||||||
fourK : number
|
fourK : number
|
||||||
fiveK : number
|
fiveK : number
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------- */
|
/* --------------------------------------------------------------- */
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
// /types/team.ts
|
// /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 = {
|
export type Player = {
|
||||||
steamId: string
|
steamId: string
|
||||||
name: string
|
name: string
|
||||||
@ -7,6 +17,7 @@ export type Player = {
|
|||||||
location?: string
|
location?: string
|
||||||
premierRank?: number
|
premierRank?: number
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
|
banStatus?: BanStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvitedPlayer = Player & {
|
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(
|
export async function getNextShareCodeFromAPI(
|
||||||
steamId: string,
|
steamId: string,
|
||||||
authCode: string,
|
authCode: string,
|
||||||
currentCode: string
|
currentCode: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://localhost:3000/api/cs2/getNextCode`, {
|
const res = await fetch(`${CS2_INTERNAL_API_URL}/api/cs2/getNextCode`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ steamId, authCode, currentCode }),
|
body: JSON.stringify({ steamId, authCode, currentCode }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
const contentType = res.headers.get('content-type') || '';
|
if (!ct.includes('application/json')) return null;
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
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;
|
return data.nextCode;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ Fehler beim Abrufen des nächsten ShareCodes für ${steamId}:`, 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