initial commit
This commit is contained in:
commit
83bc61c1a4
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
Dockerfile
|
||||||
|
Caddyfile
|
||||||
|
deploy
|
||||||
|
dist
|
||||||
|
node_modules
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.sqlite
|
6
Caddyfile
Normal file
6
Caddyfile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pubg.adam.id.au {
|
||||||
|
import ssl-cloudflare-dns
|
||||||
|
import strict-transport-security
|
||||||
|
|
||||||
|
reverse_proxy http://pubg:3000
|
||||||
|
}
|
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from aburgess/node:22-npm as deps
|
||||||
|
|
||||||
|
workdir /app
|
||||||
|
add package.json pnpm-lock.yaml ./
|
||||||
|
run pnpm install --prod
|
||||||
|
|
||||||
|
from deps as builder
|
||||||
|
|
||||||
|
run pnpm install
|
||||||
|
add . .
|
||||||
|
run nrr build
|
||||||
|
|
||||||
|
from aburgess/node:22
|
||||||
|
|
||||||
|
workdir /app
|
||||||
|
run apk add --no-cache tzdata
|
||||||
|
add package.json ./
|
||||||
|
copy --from=deps /app/node_modules /app/node_modules
|
||||||
|
copy --from=builder /app/dist /app/dist
|
||||||
|
|
||||||
|
entrypoint ["node", "/app/dist/main.js"]
|
17
compose.yml
Normal file
17
compose.yml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
name: pubg
|
||||||
|
|
||||||
|
services:
|
||||||
|
pubg:
|
||||||
|
build: .
|
||||||
|
container_name: pubg
|
||||||
|
hostname: pubg
|
||||||
|
restart: always
|
||||||
|
init: true
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- /home/adam/d:/data
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: platform
|
||||||
|
external: true
|
6
deploy
Executable file
6
deploy
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DOCKER_HOST=ssh://adam@adam.id.au docker compose up -d --build
|
||||||
|
DEPLOY_PROJECT=pubg DEPLOY_CADDYFILE=Caddyfile node /tmp/client.mjs
|
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "pubg",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@adamburgess/linq": "^3.0.0",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"change-case": "^5.4.4",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"preact": "^10.25.3",
|
||||||
|
"preact-render-to-string": "^6.5.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "esbuild src/*.ts src/*.tsx --outdir=dist --format=esm --platform=node"
|
||||||
|
}
|
||||||
|
}
|
1158
pnpm-lock.yaml
generated
Normal file
1158
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
77
src/database.ts
Normal file
77
src/database.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import knex from 'knex';
|
||||||
|
import type { MatchResponse, PlayerResponse } from './pubg-api.js';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
id: number
|
||||||
|
data: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Match {
|
||||||
|
match_id: string
|
||||||
|
data: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Database() {
|
||||||
|
const db = knex({
|
||||||
|
client: 'better-sqlite3',
|
||||||
|
connection: {
|
||||||
|
filename: 'DOCKER' in process.env ? '/data/pubg.sqlite' : './pubg.sqlite'
|
||||||
|
},
|
||||||
|
useNullAsDefault: true
|
||||||
|
});
|
||||||
|
|
||||||
|
db.on('start', builder => {
|
||||||
|
console.log(builder.toQuery());
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!await db.schema.hasTable('player')) {
|
||||||
|
await db.schema.createTable('player', table => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.json('data');
|
||||||
|
table.integer('created_at').notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await db.schema.hasTable('match')) {
|
||||||
|
await db.schema.createTable('match', table => {
|
||||||
|
table.string('match_id').primary();
|
||||||
|
table.json('data');
|
||||||
|
table.integer('created_at').notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class Database {
|
||||||
|
async getCachedPlayer() {
|
||||||
|
const sec = dayjs().add(-10, 'seconds').unix();
|
||||||
|
|
||||||
|
const r = await db<Player>('player').where('created_at', '>', sec).first();
|
||||||
|
console.log('cached player response', r);
|
||||||
|
if (r) return JSON.parse(r.data) as PlayerResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlayer(player: PlayerResponse) {
|
||||||
|
await db<Player>('player').insert({
|
||||||
|
data: JSON.stringify(player),
|
||||||
|
created_at: dayjs().unix()
|
||||||
|
});
|
||||||
|
console.log('saved cached player response', player);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMatch(match_id: string) {
|
||||||
|
const match = await db<Match>('match').where('match_id', match_id).first();
|
||||||
|
if (match) return JSON.parse(match.data) as MatchResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMatch(match: MatchResponse) {
|
||||||
|
const match_id = match.data.id;
|
||||||
|
await db<Match>('match').insert({
|
||||||
|
match_id,
|
||||||
|
data: JSON.stringify(match),
|
||||||
|
created_at: dayjs().unix()
|
||||||
|
}).onConflict('match_id').ignore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
634
src/main.tsx
Normal file
634
src/main.tsx
Normal file
@ -0,0 +1,634 @@
|
|||||||
|
import Fastify from 'fastify'
|
||||||
|
import { Database } from './database.js';
|
||||||
|
import { 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 getChickenDinnerUrl(match: MatchResponse, player: PlayerResponse['data'][0]) {
|
||||||
|
const asset = match.included.find(i => i.type === 'asset')!;
|
||||||
|
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 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 date = dayjs(m.data.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={getChickenDinnerUrl(m, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast body {
|
||||||
|
color: #050505
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast blockquote {
|
||||||
|
color: #11151a
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast blockquote:before {
|
||||||
|
color: #262626
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast a {
|
||||||
|
color: #03f
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast a:visited {
|
||||||
|
color: #7d013e
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast span.wr {
|
||||||
|
color: #800
|
||||||
|
}
|
||||||
|
|
||||||
|
html.contrast span.mfw {
|
||||||
|
color: #4d0000
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (prefers-color-scheme:light) {
|
||||||
|
html.inverted {
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted body {
|
||||||
|
color: #d9d9d9
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted #contrast,
|
||||||
|
html.inverted #invmode {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted blockquote {
|
||||||
|
color: #d3c9be
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted blockquote:before {
|
||||||
|
color: #b8b8b8
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted a {
|
||||||
|
color: #00a2e7
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted a:visited {
|
||||||
|
color: #ca1a70
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted span.wr {
|
||||||
|
color: #d24637
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted span.mfw {
|
||||||
|
color: #b00000
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast {
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast body {
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast #contrast,
|
||||||
|
html.inverted.contrast #invmode {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast blockquote {
|
||||||
|
color: #f8f6f5
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast blockquote:before {
|
||||||
|
color: #e5e5e5
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast a {
|
||||||
|
color: #44c7ff
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast a:visited {
|
||||||
|
color: #e9579e
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast span.wr {
|
||||||
|
color: #db695d
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted.contrast span.mfw {
|
||||||
|
color: #ff0d0d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme:dark) {
|
||||||
|
html:not(.inverted) {
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) body {
|
||||||
|
color: #d9d9d9
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) #contrast,
|
||||||
|
html:not(.inverted) #invmode {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) blockquote {
|
||||||
|
color: #d3c9be
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) blockquote:before {
|
||||||
|
color: #b8b8b8
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) a {
|
||||||
|
color: #00a2e7
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) a:visited {
|
||||||
|
color: #ca1a70
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) span.wr {
|
||||||
|
color: #d24637
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted) span.mfw {
|
||||||
|
color: #b00000
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast {
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast body {
|
||||||
|
color: #fff
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast #contrast,
|
||||||
|
html:not(.inverted).contrast #invmode {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast blockquote {
|
||||||
|
color: #f8f6f5
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast blockquote:before {
|
||||||
|
color: #e5e5e5
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast a {
|
||||||
|
color: #44c7ff
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast a:visited {
|
||||||
|
color: #e9579e
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast span.wr {
|
||||||
|
color: #db695d
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.inverted).contrast span.mfw {
|
||||||
|
color: #ff0d0d
|
||||||
|
}
|
||||||
|
|
||||||
|
html.inverted html {
|
||||||
|
background-color: #fefefe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #07a
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #941352
|
||||||
|
}
|
||||||
|
|
||||||
|
.noselect {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none
|
||||||
|
}
|
||||||
|
|
||||||
|
span.citneed {
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: .7em;
|
||||||
|
padding-left: .3em
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: .4em
|
||||||
|
}
|
||||||
|
|
||||||
|
p.st {
|
||||||
|
margin-top: -1em
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.picture-left {
|
||||||
|
float: left;
|
||||||
|
width: 40%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.picture-left img {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.picture-left figure {
|
||||||
|
margin: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.picture-left figure figcaption {
|
||||||
|
font-size: .7em
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.tleft {
|
||||||
|
float: left;
|
||||||
|
width: 55%
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.tleft p:first-child {
|
||||||
|
margin-top: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning:after {
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
clear: both
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li img {
|
||||||
|
height: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
color: #456;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote span {
|
||||||
|
float: left;
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding-top: 1rem
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote author {
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
font-size: .6em;
|
||||||
|
margin-left: 2.4rem;
|
||||||
|
font-style: oblique
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote author:before {
|
||||||
|
content: "- ";
|
||||||
|
margin-right: 1em
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote:before {
|
||||||
|
font-family: Times New Roman, Times, Arial;
|
||||||
|
color: #666;
|
||||||
|
content: open-quote;
|
||||||
|
font-size: 2.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
float: left;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-right: .2rem;
|
||||||
|
width: 1.2rem
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote:after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
clear: both
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width:500px) {
|
||||||
|
body {
|
||||||
|
text-align: left
|
||||||
|
}
|
||||||
|
|
||||||
|
div.fancyPositioning div.picture-left,
|
||||||
|
div.fancyPositioning div.tleft {
|
||||||
|
float: none;
|
||||||
|
width: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote span {
|
||||||
|
width: 80%
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote author {
|
||||||
|
padding-top: 1em;
|
||||||
|
width: 80%;
|
||||||
|
margin-left: 15%
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote author:before {
|
||||||
|
content: "";
|
||||||
|
margin-right: inherit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span.visited {
|
||||||
|
color: #941352
|
||||||
|
}
|
||||||
|
|
||||||
|
span.visited-maroon {
|
||||||
|
color: #85144b
|
||||||
|
}
|
||||||
|
|
||||||
|
span.wr {
|
||||||
|
color: #c0392b;
|
||||||
|
font-weight: 600
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cont-inv,
|
||||||
|
span.wr {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cont-inv {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: fixed;
|
||||||
|
right: 10px;
|
||||||
|
font-size: .8em;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
#contrast {
|
||||||
|
color: #000;
|
||||||
|
top: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
#contrast,
|
||||||
|
#invmode {
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none
|
||||||
|
}
|
||||||
|
|
||||||
|
#invmode {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #000;
|
||||||
|
position: fixed;
|
||||||
|
top: 34px;
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width:1080px) {
|
||||||
|
|
||||||
|
#contrast,
|
||||||
|
#invmode {
|
||||||
|
position: absolute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span.sb {
|
||||||
|
color: #00e
|
||||||
|
}
|
||||||
|
|
||||||
|
span.sb,
|
||||||
|
span.sv {
|
||||||
|
cursor: not-allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
span.sv {
|
||||||
|
color: #551a8b
|
||||||
|
}
|
||||||
|
|
||||||
|
span.foufoufou {
|
||||||
|
color: #444;
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
span.foufoufou:before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: .2em;
|
||||||
|
margin-right: .2em;
|
||||||
|
background-color: #444
|
||||||
|
}
|
||||||
|
|
||||||
|
span.foufivfoufivfoufiv {
|
||||||
|
color: #454545;
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
span.foufivfoufivfoufiv:before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: .2em;
|
||||||
|
margin-right: .2em;
|
||||||
|
background-color: #454545
|
||||||
|
}
|
||||||
|
|
||||||
|
span.mfw {
|
||||||
|
color: #730000
|
||||||
|
}
|
||||||
|
|
||||||
|
a.kopimi,
|
||||||
|
a.kopimi img.kopimi {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto
|
||||||
|
}
|
||||||
|
|
||||||
|
a.kopimi img.kopimi {
|
||||||
|
height: 2em
|
||||||
|
}
|
||||||
|
|
||||||
|
p.fakepre {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: .9em
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the server!
|
||||||
|
try {
|
||||||
|
await fastify.listen({ port: 3000, host: '0.0.0.0' })
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
132
src/pubg-api.ts
Normal file
132
src/pubg-api.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import * as changeCase from 'change-case';
|
||||||
|
|
||||||
|
const API_KEY = process.env.API_KEY;
|
||||||
|
|
||||||
|
function getAcceptHeaders() {
|
||||||
|
return {
|
||||||
|
'Accept': 'application/vnd.api+json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
...getAcceptHeaders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerResponse {
|
||||||
|
data: {
|
||||||
|
id: string
|
||||||
|
attributes: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
relationships: {
|
||||||
|
matches: Relationship
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Relationship {
|
||||||
|
data: {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResponse {
|
||||||
|
data: {
|
||||||
|
id: string
|
||||||
|
attributes: {
|
||||||
|
mapName: string
|
||||||
|
duration: number
|
||||||
|
gameMode: string
|
||||||
|
createdAt: string
|
||||||
|
},
|
||||||
|
relationships: {
|
||||||
|
rosters: Relationship
|
||||||
|
assets: Relationship
|
||||||
|
}
|
||||||
|
},
|
||||||
|
included: (Participant | Roster | Asset)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
type: 'participant'
|
||||||
|
id: string
|
||||||
|
attributes: {
|
||||||
|
stats: {
|
||||||
|
playerId: string
|
||||||
|
name: string
|
||||||
|
winPlace: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface Roster {
|
||||||
|
type: 'roster'
|
||||||
|
id: string
|
||||||
|
attributes: {
|
||||||
|
stats: {
|
||||||
|
rank: number
|
||||||
|
teamId: number
|
||||||
|
}
|
||||||
|
won: string
|
||||||
|
}
|
||||||
|
relationships: {
|
||||||
|
participants: Relationship
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface Asset {
|
||||||
|
type: 'asset'
|
||||||
|
id: string
|
||||||
|
attributes: {
|
||||||
|
createdAt: string
|
||||||
|
URL: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapLookup: Record<string, string> = {
|
||||||
|
"Baltic_Main": "Erangel",
|
||||||
|
"Chimera_Main": "Paramo",
|
||||||
|
"Desert_Main": "Miramar",
|
||||||
|
"DihorOtok_Main": "Vikendi",
|
||||||
|
"Erangel_Main": "Erangel",
|
||||||
|
"Heaven_Main": "Haven",
|
||||||
|
"Kiki_Main": "Deston",
|
||||||
|
"Range_Main": "Camp Jackal",
|
||||||
|
"Savage_Main": "Sanhok",
|
||||||
|
"Summerland_Main": "Karakin",
|
||||||
|
"Tiger_Main": "Taego",
|
||||||
|
"Neon_Main": "Rondo"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PubgApi {
|
||||||
|
async getPlayers(playerNames: string[]) {
|
||||||
|
const result = await fetch('https://api.pubg.com/shards/steam/players?filter[playerNames]=' + playerNames.join(','), {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!result.ok) throw new Error('cant fetch players');
|
||||||
|
const json = await result.json() as PlayerResponse;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMatch(id: string) {
|
||||||
|
const result = await fetch('https://api.pubg.com/shards/steam/matches/' + id, {
|
||||||
|
headers: getAcceptHeaders()
|
||||||
|
});
|
||||||
|
if (!result.ok) throw new Error('cant fetch');
|
||||||
|
const json = await result.json() as MatchResponse;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
static mapName(name: string) {
|
||||||
|
return mapLookup[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
static modeName(name: string) {
|
||||||
|
console.log(name);
|
||||||
|
let [team, per] = name.split('-');
|
||||||
|
if(!per) per = 'tpp';
|
||||||
|
return per.toUpperCase() + ' ' + changeCase.capitalCase(team);
|
||||||
|
}
|
||||||
|
}
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"strict": true,
|
||||||
|
"target": "esnext",
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h",
|
||||||
|
"jsxFragmentFactory": "Fragment"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user