201 lines
6.4 KiB
TypeScript
201 lines
6.4 KiB
TypeScript
import Fastify from 'fastify'
|
|
import { Database } from './database.js';
|
|
import { Asset, MatchResponse, PlayerResponse, PubgApi } from './pubg-api.js';
|
|
import { Mutex } from 'async-mutex';
|
|
import { render, renderToStringAsync } from 'preact-render-to-string';
|
|
import { h, Fragment } from 'preact';
|
|
import dayjs from 'dayjs';
|
|
import utc from 'dayjs/plugin/utc.js'
|
|
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
|
dayjs.extend(utc);
|
|
dayjs.extend(relativeTime);
|
|
|
|
const fastify = Fastify({
|
|
logger: true
|
|
});
|
|
|
|
const db = await Database();
|
|
const api = new PubgApi();
|
|
|
|
|
|
const playerMutex = new Mutex();
|
|
async function getCachedPlayers() {
|
|
return await playerMutex.runExclusive(async () => {
|
|
const playerNames = ['Pigophone', 'The-High-Ground', 'Tokar-TK'];
|
|
|
|
const cached = await db.getCachedPlayer();
|
|
if (cached) return cached;
|
|
|
|
const players = await api.getPlayers(playerNames);
|
|
await db.savePlayer(players);
|
|
|
|
return players;
|
|
});
|
|
}
|
|
async function getMatch(id: string) {
|
|
const cached = await db.getMatch(id);
|
|
if (cached) return cached;
|
|
|
|
const match = await api.getMatch(id);
|
|
await db.saveMatch(match);
|
|
|
|
return match;
|
|
}
|
|
|
|
function getBotCount(match: MatchResponse) {
|
|
// bots have account id starting with 'ai.'
|
|
let playerCount = 0;
|
|
let botCount = 0;
|
|
for (const player of match.included) {
|
|
if (player.type === 'participant') {
|
|
playerCount++;
|
|
if (player.attributes.stats.playerId.startsWith('ai.')) {
|
|
botCount++;
|
|
}
|
|
}
|
|
}
|
|
return { playerCount, botCount, humanCount: playerCount - botCount };
|
|
}
|
|
|
|
function getTelemetry(match: MatchResponse) {
|
|
const asset = match.included.find(i => i.type === 'asset')!;
|
|
return asset!;
|
|
}
|
|
|
|
function getChickenDinnerUrl(asset: Asset, player: PlayerResponse['data'][0]) {
|
|
const url = asset.attributes.URL.substring('https://telemetry-cdn.pubg.com/bluehole-pubg/'.length).replace('-telemetry.json', '');
|
|
return `https://chickendinner.gg/${url}?follow=${player.attributes.name}`;
|
|
}
|
|
|
|
function getChickenDinnerUrlv2(matchId: string, player: PlayerResponse['data'][0]) {
|
|
return `https://chickendinner.gg/${matchId}/${player.attributes.name}`;
|
|
}
|
|
|
|
function getRank(match: MatchResponse, playerId: string) {
|
|
for (const player of match.included) {
|
|
if (player.type === 'participant' && player.attributes.stats.playerId === playerId) {
|
|
return player.attributes.stats.winPlace;
|
|
}
|
|
}
|
|
return 'N/A';
|
|
}
|
|
|
|
function MatchesHtml({ players, matches }: { players: PlayerResponse, matches: Record<string, MatchResponse> }) {
|
|
return (
|
|
<html>
|
|
<head>
|
|
<title>Matches</title>
|
|
<link rel="stylesheet" type="text/css" href="/style.css" />
|
|
<style>{`
|
|
li a { padding: 0 5px; }
|
|
|
|
#matches {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
|
}
|
|
`}</style>
|
|
</head>
|
|
<body>
|
|
<h1>Matches</h1>
|
|
{players.data.map(player => (
|
|
<>
|
|
<h2 id={player.attributes.name}>
|
|
{player.attributes.name}
|
|
</h2>
|
|
<div id="matches">
|
|
{player.relationships.matches.data.map(match => {
|
|
var m = matches[match.id];
|
|
const playerName = player.attributes.name;
|
|
var map = PubgApi.mapName(m.data.attributes.mapName);
|
|
var rank = getRank(m, player.id);
|
|
var count = getBotCount(m);
|
|
const telemetry = getTelemetry(m);
|
|
const date = dayjs(telemetry.attributes.createdAt);
|
|
|
|
return <div>
|
|
<ul>
|
|
<li>Map: {map}</li>
|
|
<li>Mode: {PubgApi.modeName(m.data.attributes.gameMode)}</li>
|
|
<li>Date: {date.format('ddd DD/MM HH:mm')} ({date.fromNow()})</li>
|
|
<li>{playerName}'s rank: {rank}</li>
|
|
<li>Players: {count.humanCount} (bots: {count.botCount} - {`${Math.round(count.botCount / count.playerCount * 100)}%`})</li>
|
|
<li>Links:
|
|
<a href={`/match/${match.id}`}>Raw</a>
|
|
<a href={`https://pubglookup.com/players/steam/${player.attributes.name.toLowerCase()}/matches/${match.id}`}>PUBGLookup</a>
|
|
<a href={getChickenDinnerUrlv2(match.id, player)}>map replay</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
})}
|
|
</div>
|
|
</>
|
|
))}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
|
|
fastify.get('/matches', async (req, res) => {
|
|
const players = await getCachedPlayers();
|
|
|
|
const matches: Record<string, MatchResponse> = {};
|
|
const matchIds = new Set<string>();
|
|
for (const player of players.data) {
|
|
for (const match of player.relationships.matches.data) {
|
|
matchIds.add(match.id);
|
|
}
|
|
}
|
|
await Promise.all([...matchIds].map(async id => {
|
|
matches[id] = await getMatch(id);
|
|
}));
|
|
|
|
const html = render(<MatchesHtml players={players} matches={matches} />);
|
|
|
|
res.header('Content-Type', 'text/html');
|
|
|
|
return html;
|
|
});
|
|
|
|
fastify.get<{ Params: { id: string } }>('/match/:id', async (req, res) => {
|
|
const match = await getMatch(req.params.id);
|
|
return match;
|
|
});
|
|
|
|
fastify.get('/style.css', async (req, res) => {
|
|
res.header('Content-Type', 'text/css');
|
|
return `
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: Open Sans, Arial;
|
|
color: #454545;
|
|
font-size: 16px;
|
|
margin: 2em auto;
|
|
max-width: 1600px;
|
|
padding: 1em;
|
|
line-height: 1.4;
|
|
-webkit-hyphens: auto;
|
|
-ms-hyphens: auto;
|
|
hyphens: auto
|
|
}
|
|
|
|
a {
|
|
color: #07a
|
|
}
|
|
|
|
a:visited {
|
|
color: #941352
|
|
}
|
|
`;
|
|
});
|
|
|
|
// Run the server!
|
|
try {
|
|
await fastify.listen({ port: 3000, host: '0.0.0.0' })
|
|
} catch (err) {
|
|
fastify.log.error(err)
|
|
process.exit(1)
|
|
}
|