This commit is contained in:
@@ -588,6 +588,10 @@ export class MatchesService {
|
||||
teamStats: [],
|
||||
playerParticipations: (() => {
|
||||
const parsed: Array<{ teamId: string; isStarting: boolean; shirtNumber: string | number | null; position: string | null; player: { id: string; name: string } }> = [];
|
||||
const canTrustFeedLineups = displayStatus === "LIVE" || displayStatus === "Finished";
|
||||
if (!canTrustFeedLineups) {
|
||||
return parsed;
|
||||
}
|
||||
if (liveMatch.lineups && typeof liveMatch.lineups === 'object') {
|
||||
const lu = liveMatch.lineups as Record<string, any>;
|
||||
const addPlayers = (teamLu: any, teamId: string | null) => {
|
||||
@@ -630,6 +634,64 @@ export class MatchesService {
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const detailDisplayStatus = getDisplayMatchStatus({
|
||||
state: match.state,
|
||||
status: match.status,
|
||||
substate: match.substate,
|
||||
scoreHome: match.scoreHome,
|
||||
scoreAway: match.scoreAway,
|
||||
});
|
||||
const canTrustStoredLineups = this.canTrustStoredLineups(detailDisplayStatus);
|
||||
|
||||
if (Array.isArray(match.playerParticipations)) {
|
||||
if (!canTrustStoredLineups) {
|
||||
match.playerParticipations = [];
|
||||
}
|
||||
|
||||
const hasHomeLineup = match.playerParticipations.some(
|
||||
(p: any) => p.teamId === match.homeTeamId && p.isStarting,
|
||||
);
|
||||
const hasAwayLineup = match.playerParticipations.some(
|
||||
(p: any) => p.teamId === match.awayTeamId && p.isStarting,
|
||||
);
|
||||
|
||||
if (!hasHomeLineup || !hasAwayLineup) {
|
||||
const sidelined =
|
||||
match.sidelined && typeof match.sidelined === "object"
|
||||
? (match.sidelined as Record<string, any>)
|
||||
: {};
|
||||
const matchDateMs = Number(match.mstUtc || Date.now());
|
||||
const probableLineups: any[] = [];
|
||||
|
||||
if (!hasHomeLineup && match.homeTeamId) {
|
||||
probableLineups.push(
|
||||
...(await this.buildProbableLineupForTeam({
|
||||
teamId: match.homeTeamId,
|
||||
beforeDateMs: matchDateMs,
|
||||
sidelinedTeamData: sidelined.homeTeam,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAwayLineup && match.awayTeamId) {
|
||||
probableLineups.push(
|
||||
...(await this.buildProbableLineupForTeam({
|
||||
teamId: match.awayTeamId,
|
||||
beforeDateMs: matchDateMs,
|
||||
sidelinedTeamData: sidelined.awayTeam,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (probableLineups.length > 0) {
|
||||
match.playerParticipations = canTrustStoredLineups
|
||||
? [...match.playerParticipations, ...probableLineups]
|
||||
: probableLineups;
|
||||
match.lineupSource = "probable_xi";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Structure odds
|
||||
const odds: Record<
|
||||
string,
|
||||
@@ -732,4 +794,211 @@ export class MatchesService {
|
||||
|
||||
return team?.id || null;
|
||||
}
|
||||
|
||||
private async buildProbableLineupForTeam(params: {
|
||||
teamId: string;
|
||||
beforeDateMs: number;
|
||||
sidelinedTeamData?: any;
|
||||
matchLimit?: number;
|
||||
lookbackDays?: number;
|
||||
maxStalenessDays?: number;
|
||||
}) {
|
||||
const matchLimit = params.matchLimit ?? 5;
|
||||
const lookbackDays = params.lookbackDays ?? 370;
|
||||
const maxStalenessDays = params.maxStalenessDays ?? 120;
|
||||
const beforeDateMs = params.beforeDateMs || Date.now();
|
||||
const minDateMs = Math.max(
|
||||
0,
|
||||
beforeDateMs - lookbackDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const excluded = this.extractSidelinedPlayerIds(params.sidelinedTeamData);
|
||||
|
||||
const rows = await this.prisma.$queryRaw<any[]>`
|
||||
SELECT
|
||||
mpp.player_id AS "playerId",
|
||||
p.name AS "playerName",
|
||||
mpp.position AS "position",
|
||||
mpp.shirt_number AS "shirtNumber",
|
||||
m.id AS "matchId",
|
||||
m.mst_utc AS "mstUtc"
|
||||
FROM match_player_participation mpp
|
||||
JOIN matches m ON m.id = mpp.match_id
|
||||
JOIN players p ON p.id = mpp.player_id
|
||||
WHERE mpp.team_id = ${params.teamId}
|
||||
AND mpp.is_starting = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM match_player_participation later_mpp
|
||||
JOIN matches later_m ON later_m.id = later_mpp.match_id
|
||||
WHERE later_mpp.player_id = mpp.player_id
|
||||
AND later_mpp.team_id <> ${params.teamId}
|
||||
AND later_m.mst_utc > m.mst_utc
|
||||
AND later_m.mst_utc < ${BigInt(beforeDateMs)}
|
||||
AND (
|
||||
later_m.status = 'FT'
|
||||
OR later_m.state = 'postGame'
|
||||
OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
|
||||
)
|
||||
)
|
||||
AND m.id IN (
|
||||
SELECT m2.id
|
||||
FROM matches m2
|
||||
JOIN match_player_participation recent_mpp
|
||||
ON recent_mpp.match_id = m2.id
|
||||
AND recent_mpp.team_id = ${params.teamId}
|
||||
AND recent_mpp.is_starting = true
|
||||
WHERE (m2.home_team_id = ${params.teamId} OR m2.away_team_id = ${params.teamId})
|
||||
AND (
|
||||
m2.status = 'FT'
|
||||
OR m2.state = 'postGame'
|
||||
OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
|
||||
)
|
||||
AND m2.mst_utc < ${BigInt(beforeDateMs)}
|
||||
AND m2.mst_utc >= ${BigInt(minDateMs)}
|
||||
GROUP BY m2.id
|
||||
HAVING COUNT(recent_mpp.*) >= 9
|
||||
ORDER BY MAX(m2.mst_utc) DESC
|
||||
LIMIT ${matchLimit}
|
||||
)
|
||||
ORDER BY m.mst_utc DESC
|
||||
`;
|
||||
|
||||
if (!rows.length) return [];
|
||||
|
||||
const latestMst = Math.max(
|
||||
...rows.map((row) => Number(row.mstUtc || 0)),
|
||||
);
|
||||
const ageDays =
|
||||
latestMst > 0
|
||||
? (beforeDateMs - latestMst) / (24 * 60 * 60 * 1000)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const staleProjection = ageDays > maxStalenessDays;
|
||||
|
||||
const matchOrder = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const matchId = String(row.matchId);
|
||||
if (!matchOrder.has(matchId)) {
|
||||
matchOrder.set(matchId, matchOrder.size);
|
||||
}
|
||||
}
|
||||
|
||||
const playerMap = new Map<
|
||||
string,
|
||||
{
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
position: string | null;
|
||||
shirtNumber: number | null;
|
||||
score: number;
|
||||
starts: number;
|
||||
lastSeenRank: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const playerId = String(row.playerId);
|
||||
if (excluded.has(playerId)) continue;
|
||||
|
||||
const rank = matchOrder.get(String(row.matchId)) ?? matchLimit;
|
||||
const recencyWeight = Math.max(1, matchLimit - rank);
|
||||
const score =
|
||||
recencyWeight + (rank === 0 ? 3 : rank === 1 ? 1.5 : 0);
|
||||
const existing = playerMap.get(playerId);
|
||||
|
||||
if (!existing) {
|
||||
playerMap.set(playerId, {
|
||||
playerId,
|
||||
playerName: row.playerName || "Bilinmiyor",
|
||||
position: row.position ?? null,
|
||||
shirtNumber:
|
||||
row.shirtNumber === null || row.shirtNumber === undefined
|
||||
? null
|
||||
: Number(row.shirtNumber),
|
||||
score,
|
||||
starts: 1,
|
||||
lastSeenRank: rank,
|
||||
});
|
||||
} else {
|
||||
existing.score += score;
|
||||
existing.starts += 1;
|
||||
existing.lastSeenRank = Math.min(existing.lastSeenRank, rank);
|
||||
existing.position = existing.position || row.position || null;
|
||||
existing.shirtNumber =
|
||||
existing.shirtNumber ??
|
||||
(row.shirtNumber === null || row.shirtNumber === undefined
|
||||
? null
|
||||
: Number(row.shirtNumber));
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = [...playerMap.values()]
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (b.starts !== a.starts) return b.starts - a.starts;
|
||||
return a.lastSeenRank - b.lastSeenRank;
|
||||
})
|
||||
.slice(0, 11);
|
||||
|
||||
const coverage = Math.min(1, ranked.length / 11);
|
||||
const historyScore = Math.min(1, matchOrder.size / matchLimit);
|
||||
const stableCore = ranked.filter((p) => p.starts >= 2).length / 11;
|
||||
const stalenessFactor = Math.max(
|
||||
0.35,
|
||||
Math.min(1, maxStalenessDays / Math.max(ageDays, 1)),
|
||||
);
|
||||
const confidence = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
staleProjection ? 0.58 : 0.88,
|
||||
(coverage * 0.45 + historyScore * 0.25 + stableCore * 0.3) *
|
||||
stalenessFactor,
|
||||
),
|
||||
);
|
||||
|
||||
return ranked.map((p) => ({
|
||||
teamId: params.teamId,
|
||||
isStarting: true,
|
||||
shirtNumber: p.shirtNumber,
|
||||
position: p.position,
|
||||
isProbable: true,
|
||||
lineupSource: "probable_xi",
|
||||
projectionConfidence: Number(confidence.toFixed(3)),
|
||||
projectionAgeDays: Number(ageDays.toFixed(1)),
|
||||
projectionStale: staleProjection,
|
||||
projectionMatchLimit: matchLimit,
|
||||
projectionLookbackDays: lookbackDays,
|
||||
projectionMaxStalenessDays: maxStalenessDays,
|
||||
player: {
|
||||
id: p.playerId,
|
||||
name: p.playerName,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private extractSidelinedPlayerIds(teamData: any): Set<string> {
|
||||
if (!teamData || typeof teamData !== "object") return new Set();
|
||||
const players = Array.isArray(teamData.players) ? teamData.players : [];
|
||||
return new Set(
|
||||
players
|
||||
.map((player: any) =>
|
||||
String(
|
||||
player?.playerId ??
|
||||
player?.player_id ??
|
||||
player?.id ??
|
||||
player?.personId ??
|
||||
"",
|
||||
),
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
private canTrustStoredLineups(displayStatus?: string): boolean {
|
||||
const normalized = String(displayStatus || "").toLowerCase();
|
||||
return (
|
||||
normalized === "live" ||
|
||||
normalized === "finished" ||
|
||||
normalized === "ft"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user