├── .babelrc ├── .eslintrc.json ├── public ├── img │ └── bg.jpg ├── favicon.ico └── vercel.svg ├── components ├── dataContext.ts ├── serverList.module.scss ├── banner.module.scss ├── filters.module.scss ├── banner.tsx ├── server.module.scss ├── server.tsx ├── serverList.tsx └── filters.tsx ├── .gitignore ├── pages ├── _app.tsx ├── index.tsx └── api │ └── servers.ts ├── next.config.js ├── tsconfig.json ├── utils ├── db.ts ├── models │ └── server.ts └── getServers.ts ├── datanotes.txt ├── package.json ├── styles └── globals.scss └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | {"presets":["next/babel"],} -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/img/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/acc-server-browser-web/HEAD/public/img/bg.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reesvarney/acc-server-browser-web/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /components/dataContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type {filterType} from "$api/servers" 3 | const DataContext = createContext({ 4 | refetch: (data: filterType)=>{}, 5 | addFavourite: (id: string)=>{}, 6 | removeFavourite: (id: string)=>{}, 7 | status: "online" 8 | }); 9 | export default DataContext; -------------------------------------------------------------------------------- /components/serverList.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .servers{ 3 | box-sizing: content-box; 4 | padding: 0px; 5 | margin: 0px; 6 | display: block; 7 | overflow-y: auto; 8 | height: 100%; 9 | width: 100%; 10 | max-height: 100%; 11 | } 12 | 13 | .servers>tbody{ 14 | display: table; 15 | width: 100%; 16 | border-spacing: 0px 2px; 17 | } 18 | 19 | .table_container { 20 | max-height: 100%; 21 | overflow: hidden; 22 | flex-grow: 1; 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env 38 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.scss"; 2 | import type { AppProps } from "next/app"; 3 | import { config } from "@fortawesome/fontawesome-svg-core"; 4 | import "@fortawesome/fontawesome-svg-core/styles.css"; 5 | import { GoogleAnalytics } from "nextjs-google-analytics"; 6 | 7 | config.autoAddCss = false; 8 | 9 | function MyApp({ Component, pageProps }: AppProps) { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | export default MyApp; 19 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | experimental: { images: { allowFutureImage: true } }, 6 | webpack: (config) =>{ 7 | config.experiments = { 8 | ...config.experiments, 9 | ...{"topLevelAwait": true} 10 | } 11 | return config 12 | }, 13 | } 14 | if(process.env.ANALYZE === 'true'){ 15 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 16 | enabled: process.env.ANALYZE === 'true' 17 | }) 18 | module.exports = withBundleAnalyzer(nextConfig) 19 | } else { 20 | module.exports = nextConfig 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "$components/*": ["./components/*"], 19 | "$utils/*": ["./utils/*"], 20 | "$pages/*": ["./pages/*"], 21 | "$api/*": ["./pages/api/*"], 22 | }, 23 | "baseUrl": "." 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /utils/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | let connected = false; 3 | if(!process.env.DB_URL){ 4 | if(process.env.MONGODB_URI){ 5 | process.env.DB_URL = process.env.MONGODB_URI 6 | } else { 7 | console.log("Creating memory db"); 8 | const { MongoMemoryServer } = await import('mongodb-memory-server'); 9 | const mongod = await MongoMemoryServer.create(); 10 | process.env.DB_URL = mongod.getUri(); 11 | } 12 | } 13 | console.log("Connecting to DB: " + process.env.DB_URL); 14 | if(!connected){ 15 | connected= true; 16 | await mongoose.connect(process.env.DB_URL, { dbName: "acc" }); 17 | } 18 | import serverSchema from "./models/server"; 19 | if(!mongoose.models.Server) mongoose.model("Server", serverSchema); 20 | export default mongoose.models; 21 | export type ServerType = mongoose.InferSchemaType & { 22 | isFavourite : boolean 23 | }; -------------------------------------------------------------------------------- /datanotes.txt: -------------------------------------------------------------------------------- 1 | ##################################################### 2 | {IP Address} 3 | [TCP PORT * 1] [TCP PORT * 256] 4 | [UDP PORT * 1] [UDP PORT * 256] 5 | XX 6 | {TRACK NAME} 7 | {SERVER NAME} 8 | XX XX 9 | [CLASS] 10 | XX XX XX XX XX XX XX XX XX XX 11 | [HOTJOIN] 12 | XX 13 | [NUM OF SESSIONS] 14 | [SESSION TYPE] [SESSION TIME 1x] [SESSION TIME 256x] 15 | [SESSION TYPE] [SESSION TIME 1x] [SESSION TIME 256x] 16 | [MAX DRIVERS] 17 | [DRIVERS CONNECTED] 18 | XX XX XX 19 | [RAIN] 20 | XX 21 | [NIGHT] 22 | [WEATHER VARIABILITY] 23 | [TRACK MEDALS] 24 | [SA] 25 | XX XX XX XX XX XX XX XX 26 | [ACTIVE SESSION] 27 | #################################################### 28 | 29 | {} = Dynamic length (Determined length by preceeding hex pair) 30 | [] = Single hex pair 31 | 32 | NEED TO FIND: 33 | - current session time 34 | - ambient temp? 35 | - pitstop/ refuelling required? (time) 36 | - stint data? -------------------------------------------------------------------------------- /utils/models/server.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | const schema = new mongoose.Schema({ 3 | ip: String, 4 | id: String, 5 | port: { 6 | tcp: Number, 7 | udp: Number 8 | }, 9 | track: { 10 | name: String, 11 | id: String, 12 | dlc: String, 13 | image: String 14 | }, 15 | name: String, 16 | class: String, 17 | hotjoin: Boolean, 18 | numOfSessions: Number, 19 | sessions: [ 20 | { 21 | type: {type: String}, 22 | time: Number, 23 | active: Boolean 24 | } 25 | ], 26 | drivers: { 27 | max: Number, 28 | connected: Number 29 | }, 30 | conditions: { 31 | rain: Boolean, 32 | night: Boolean, 33 | variability: Number 34 | }, 35 | requirements: { 36 | trackMedals: Number, 37 | safetyRating: Number 38 | }, 39 | currentSession: Number, 40 | isFull: Boolean, 41 | country_code: { type: String, default: '' }, 42 | },{ 43 | collation: { 44 | locale : "en", 45 | strength : 1 46 | } 47 | }); 48 | export default schema; -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /components/banner.module.scss: -------------------------------------------------------------------------------- 1 | .banner { 2 | background: rgba(10, 10, 10, 0.8); 3 | padding: 10px 20px; 4 | backdrop-filter: blur(15px); 5 | 6 | h1 { 7 | margin: 0px; 8 | margin-bottom: 10px; 9 | } 10 | 11 | &>.flex_row { 12 | flex-direction: row; 13 | justify-content: space-between; 14 | align-items: flex-start; 15 | margin-bottom: 10px; 16 | gap: 10px; 17 | flex-wrap: wrap; 18 | 19 | >:first-of-type{ 20 | flex-grow: 2; 21 | } 22 | 23 | >*{ 24 | flex-grow: 1; 25 | justify-content: space-between; 26 | } 27 | 28 | .refresh_button { 29 | font-size: 1.1em; 30 | } 31 | 32 | } 33 | 34 | .news_container { 35 | display: flex; 36 | flex-direction: row; 37 | align-items: flex-end; 38 | width: 100%; 39 | justify-content: space-between; 40 | background-color: rgba(#FFFFFF, 0.1); 41 | border-radius: 10px; 42 | padding: 10px; 43 | margin-bottom: 20px; 44 | flex-wrap: wrap; 45 | } 46 | 47 | 48 | .status_area { 49 | font-size: 1.1em; 50 | 51 | .kunos_status { 52 | font-weight: bold; 53 | padding-left: 1em; 54 | text-transform: capitalize; 55 | color: red; 56 | &.online { 57 | color: green; 58 | } 59 | } 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acc-server-browser", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "analyse": "cross-env ANALYZE=true next build" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^6.1.2", 14 | "@fortawesome/free-regular-svg-icons": "^6.1.2", 15 | "@fortawesome/free-solid-svg-icons": "^6.1.2", 16 | "@fortawesome/react-fontawesome": "^0.2.0", 17 | "@trpc/client": "^9.27.2", 18 | "@trpc/next": "^9.27.2", 19 | "@trpc/react": "^9.27.2", 20 | "@trpc/server": "^9.27.2", 21 | "cross-env": "^7.0.3", 22 | "geoip-country": "^4.1.20", 23 | "mongoose": "^6.5.4", 24 | "next": "12.2.5", 25 | "nextjs-google-analytics": "^2.1.0", 26 | "react": "18.2.0", 27 | "react-dom": "18.2.0", 28 | "react-infinite-scroller": "^1.2.6", 29 | "react-query": "^3.39.2", 30 | "sass": "^1.54.6", 31 | "ws": "^8.8.1", 32 | "zod": "^3.18.0" 33 | }, 34 | "devDependencies": { 35 | "@babel/preset-env": "^7.18.10", 36 | "@next/bundle-analyzer": "^12.2.5", 37 | "@types/geoip-country": "^4.0.0", 38 | "@types/node": "18.7.13", 39 | "@types/react": "18.0.17", 40 | "@types/react-dom": "18.0.6", 41 | "@types/react-infinite-scroller": "^1.2.3", 42 | "@types/ws": "^8.5.3", 43 | "eslint": "8.23.0", 44 | "eslint-config-next": "12.2.5", 45 | "mongodb-memory-server": "^8.9.0", 46 | "typescript": "4.8.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import { Banner } from "$components/banner"; 4 | import { ServerList } from "$components/serverList"; 5 | import Image from "next/image"; 6 | import DataContext from "$components/dataContext"; 7 | import { useState } from "react"; 8 | 9 | const Home: NextPage = () => { 10 | const [status, setStatus] = useState("online"); 11 | function setStatusFromChild(a: string){ 12 | setStatus(a) 13 | } 14 | const googleVerify = process.env.NEXT_PUBLIC_GOOGLE_VERIFY; 15 | return ( 16 |
17 | 18 | ACC Server Browser Web 19 | 21 | {googleVerify && 22 | 23 | } 24 | 25 | 26 | 27 |
28 |
29 | 37 |
38 | 39 | {}, status: "online", removeFavourite: ()=>{}, addFavourite: ()=>{}}}> 40 | 41 | 42 | 43 |
44 |
45 | ); 46 | }; 47 | export default Home; 48 | -------------------------------------------------------------------------------- /components/filters.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .filters { 3 | display: flex; 4 | flex-direction: column; 5 | gap: 20px; 6 | margin: 0px; 7 | 8 | >*:not(.filter_search) { 9 | flex-direction: row; 10 | gap: 20px; 11 | } 12 | 13 | .filters_main { 14 | display: flex; 15 | position: relative; 16 | padding-left: 20px; 17 | padding-right: 20px; 18 | width: 100%; 19 | align-self: stretch; 20 | box-sizing: border-box; 21 | flex-wrap: wrap; 22 | 23 | .filter_group{ 24 | display: table; 25 | border-spacing: 10px 0px; 26 | 27 | >* { 28 | display: table-row; 29 | >*{ 30 | display: table-cell; 31 | } 32 | } 33 | .filter_group_name{ 34 | display: table-row; 35 | font-weight: 700; 36 | column-span: all; 37 | } 38 | } 39 | } 40 | 41 | .filter_search{ 42 | flex-grow: 1; 43 | display: flex; 44 | gap: 10px; 45 | 46 | input { 47 | width: auto; 48 | max-width: auto; 49 | flex-grow: 1; 50 | color: white; 51 | font-size: 1.5em; 52 | padding: 5px; 53 | border-radius: 10px; 54 | border-width: 2px; 55 | min-width: 0px; 56 | } 57 | } 58 | } 59 | 60 | .filter_search input:focus{ 61 | outline: none; 62 | border-color: #ffff; 63 | } 64 | 65 | .button_area{ 66 | margin-left: auto; 67 | justify-self: flex-end; 68 | flex-grow: 1; 69 | display: flex; 70 | flex-direction: row; 71 | justify-content: flex-end; 72 | align-items: flex-end; 73 | gap: 10px; 74 | 75 | * { 76 | padding: 5px 10px; 77 | font-weight: 500; 78 | } 79 | } 80 | 81 | #show_filters { 82 | display: block; 83 | font-size: 1.1em; 84 | padding: 0px 20px; 85 | box-shadow: inset 0 0 0 5px #1b1b1b; 86 | } 87 | 88 | #update_button{ 89 | padding: 5px 10px; 90 | font-size: 1.1em; 91 | } 92 | 93 | #hide_filters { 94 | padding: 5px 10px; 95 | font-size: 1.1em; 96 | background: none; 97 | box-shadow: inset 0 0 0 2px white; 98 | margin-right: 6px; 99 | } 100 | 101 | #hide_filters:hover { 102 | box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.712); 103 | } 104 | -------------------------------------------------------------------------------- /components/banner.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./banner.module.scss"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faRedoAlt } from "@fortawesome/free-solid-svg-icons"; 4 | import { Filters } from "./filters"; 5 | import DataContext from "./dataContext"; 6 | import { useContext, useEffect, useState } from "react"; 7 | 8 | export const Banner = ({ status }: { status: string }) => { 9 | const ctx = useContext(DataContext); 10 | const [bannerHidden, setBannerHidden] = useState(false); 11 | 12 | useEffect(() => { 13 | if (!bannerHidden) { 14 | const isHidden = JSON.parse( 15 | localStorage.getItem("newsHidden") || "false" 16 | ); 17 | if (isHidden) { 18 | setBannerHidden(true); 19 | } 20 | } else { 21 | localStorage.setItem("newsHidden", "true"); 22 | } 23 | }, [bannerHidden]); 24 | return ( 25 |
26 |
27 |

ACC Community Server Browser Project

28 |
29 |
30 | Kunos server status: 31 | 36 | {status} 37 | 38 |
39 |
40 | 44 | Report issue 45 | 46 | 58 |
59 |
60 |
61 | {!bannerHidden && ( 62 |
63 |

64 | 📰 UPDATE: ACC Server Browser has moved, if you're 65 | viewing this you are on the new site. The old URL will stop 66 | redirecting here after 28 November. A lot of the site had to be 67 | rewritten to run on the new hosting provider, some issues are known 68 | with applying filters currently though this should go away after 69 | pressing the refresh button however if you experience anything more 70 | severe please report it using the button above. 71 |

72 | 73 | 80 |
81 | )} 82 | 83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /styles/globals.scss: -------------------------------------------------------------------------------- 1 | 2 | html, 3 | body { 4 | padding: 0; 5 | margin: 0; 6 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 7 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 8 | } 9 | 10 | a:not(.link) { 11 | color: inherit; 12 | text-decoration: none; 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | } 18 | 19 | @media (prefers-color-scheme: dark) { 20 | html { 21 | color-scheme: dark; 22 | } 23 | body { 24 | color: white; 25 | background: black; 26 | } 27 | } 28 | 29 | html { 30 | min-height: 0px; 31 | max-height: 100vh; 32 | overflow: hidden; 33 | } 34 | 35 | body{ 36 | padding: 0px; 37 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 38 | color: white; 39 | display: flex; 40 | flex-direction: column; 41 | max-height: 100vh; 42 | box-sizing: content-box; 43 | margin: 0px; 44 | overflow: hidden; 45 | } 46 | 47 | .background{ 48 | position: fixed; 49 | min-width: 110vw; 50 | min-height: 110vh; 51 | top: -5vh; 52 | left: -5vw; 53 | z-index: -1; 54 | filter: brightness(80%) blur(2px); 55 | 56 | :global(*){ 57 | width: 100% !important; 58 | height: 100% !important; 59 | } 60 | } 61 | 62 | input { 63 | background: black; 64 | padding: 5px; 65 | border: 1px solid white; 66 | border-radius: 5px; 67 | outline: none; 68 | color: white; 69 | } 70 | 71 | button, .btn { 72 | box-shadow: none; 73 | color: white; 74 | border: 2px solid white; 75 | border-radius: 10px; 76 | cursor: pointer; 77 | background: black; 78 | padding: 10px 20px; 79 | font-weight: 700; 80 | } 81 | 82 | main { 83 | display: flex; 84 | flex-direction: column; 85 | justify-content: stretch; 86 | height: 100vh; 87 | overflow: hidden; 88 | max-height: 100vh; 89 | } 90 | 91 | #spinner { 92 | // flex 93 | display: none; 94 | flex-direction: column; 95 | position: absolute; 96 | top: 0px; 97 | left: 0px; 98 | width: 100%; 99 | height: 100%; 100 | justify-content: center; 101 | align-items: center; 102 | background-color: rgba(0,0,0,0.5); 103 | z-index: 2; 104 | gap: 50px; 105 | } 106 | .lds-ring { 107 | display: inline-block; 108 | position: relative; 109 | width: min(50vw, 50vh); 110 | height: min(50vw, 50vh); 111 | } 112 | .lds-ring div { 113 | box-sizing: border-box; 114 | display: block; 115 | position: absolute; 116 | width: min(50vw, 50vh); 117 | height: min(50vw, 50vh); 118 | margin: 8px; 119 | border: 8px solid #fff; 120 | border-radius: 50%; 121 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 122 | border-color: #fff transparent transparent transparent; 123 | } 124 | .lds-ring div:nth-child(1) { 125 | animation-delay: -0.45s; 126 | } 127 | .lds-ring div:nth-child(2) { 128 | animation-delay: -0.3s; 129 | } 130 | .lds-ring div:nth-child(3) { 131 | animation-delay: -0.15s; 132 | } 133 | @keyframes lds-ring { 134 | 0% { 135 | transform: rotate(0deg); 136 | } 137 | 100% { 138 | transform: rotate(360deg); 139 | } 140 | } 141 | 142 | .flex-col { 143 | display: flex; 144 | flex-direction: column; 145 | } 146 | .flex-row{ 147 | display: flex; 148 | flex-direction: row; 149 | } 150 | 151 | .flex-wrap { 152 | flex-wrap: wrap; 153 | } 154 | 155 | .grey-text { 156 | color: #aaa; 157 | } -------------------------------------------------------------------------------- /components/server.module.scss: -------------------------------------------------------------------------------- 1 | .server { 2 | background-color: rgba(5, 5, 5, 0.9); 3 | position: relative; 4 | overflow: hidden; 5 | &:nth-child(2n) { 6 | background-color: rgba(0, 0, 0, 0.9); 7 | } 8 | } 9 | 10 | .server > *:not(img) { 11 | vertical-align: middle; 12 | border: none; 13 | padding-top: 10px; 14 | padding-bottom: 10px; 15 | box-sizing: border-box; 16 | } 17 | 18 | .server_name { 19 | margin-bottom: 10px; 20 | font-size: 1.2em; 21 | 22 | a { 23 | color: rgb(0, 157, 205); 24 | 25 | &:hover { 26 | text-decoration: underline; 27 | text-decoration-thickness: 2px; 28 | } 29 | } 30 | } 31 | 32 | .server_favourite { 33 | font-size: 1.8em; 34 | height: 100%; 35 | padding-left: 20px; 36 | padding-right: 10px; 37 | } 38 | 39 | .server_favourite_icon { 40 | height: 100%; 41 | display: flex; 42 | flex-direction: column; 43 | justify-content: center; 44 | cursor: pointer; 45 | } 46 | 47 | .server_favourite_icon:hover { 48 | color: #ddd; 49 | } 50 | 51 | .server_ip { 52 | margin-right: 20px; 53 | } 54 | 55 | .flag * { 56 | max-height: 30px; 57 | max-width: 50px; 58 | font-size: 2em; 59 | } 60 | 61 | .sessions { 62 | margin-right: 20px; 63 | } 64 | 65 | .session { 66 | display: inline-block; 67 | background-color: #494949; 68 | color: rgb(255, 255, 255); 69 | padding: 5px; 70 | font-weight: 600; 71 | margin-bottom: 5px; 72 | margin-right: 10px; 73 | } 74 | 75 | .session.active { 76 | background-color: #fafafa; 77 | color: black; 78 | } 79 | 80 | /* CONDITIONS */ 81 | .conditions { 82 | display: flex; 83 | flex-direction: row; 84 | align-items: center; 85 | justify-content: flex-end; 86 | font-size: 2em; 87 | } 88 | .conditions_icon { 89 | height: 100%; 90 | display: flex; 91 | flex-direction: column; 92 | justify-content: center; 93 | } 94 | 95 | .conditions_variability { 96 | box-sizing: content-box; 97 | margin-top: 30px; 98 | margin-bottom: 30px; 99 | transform: rotate(270deg); 100 | width: 60px; 101 | height: 20px; 102 | overflow: hidden; 103 | background-color: rgba(0, 0, 0, 0.1); 104 | } 105 | .conditions_variability::-webkit-progress-bar { 106 | background-color: rgba(0, 0, 0, 0.1); 107 | } 108 | 109 | .conditions_variability::-webkit-progress-value { 110 | background-color: rgb(255, 198, 42); 111 | } 112 | 113 | .server_class { 114 | padding: 3px 7px; 115 | margin-top: 5px; 116 | justify-self: flex-start; 117 | min-width: 0px; 118 | width: fit-content; 119 | font-weight: 600; 120 | } 121 | 122 | .server_class.GT3 { 123 | background-color: #464646; 124 | color: white; 125 | } 126 | 127 | .server_class.GT4 { 128 | color: white; 129 | background-color: rgb(33, 40, 105); 130 | } 131 | 132 | .server_class.GTC { 133 | color: white; 134 | background-color: rgb(255, 136, 0); 135 | } 136 | 137 | .server_class.Mixed { 138 | background-color: white; 139 | color: black; 140 | } 141 | 142 | .server_class.TCX { 143 | background-color: rgb(1, 135, 168); 144 | color: rgb(255, 255, 255); 145 | } 146 | .server_class.GT2 { 147 | background-color: rgb(213, 75, 0); 148 | color: rgb(255, 255, 255); 149 | } 150 | .server_copy { 151 | font-size: 1.8em; 152 | height: 100%; 153 | padding-left: 20px; 154 | width: 0.1%; 155 | white-space: nowrap; 156 | } 157 | 158 | .server_copy svg { 159 | cursor: pointer; 160 | } 161 | 162 | .requirements { 163 | display: flex; 164 | flex-direction: row; 165 | gap: 10px; 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACC Community Server Browser Project 2 | View Assetto Corsa Competizione servers without opening the game. 3 | 4 | ## How it works 5 | Using captured websocket messages, the server "replays" them in order to get a list of servers from Kunos directly. The response from Kunos' server is in a (what seems to be) propriety format which I've been able to extract some of the data from however a large chunk of it is still unknown (see datanotes.txt for more information). The data is converted into JSON which is then stored in a database and served to the html client. 6 | 7 | Currently the app updates its server data every 2 minutes to avoid potentially being rate-limited or blocked by the kunos servers. This should be frequent enough to provide roughly accurate data but you can change the value in `./getServers.js` if you need. 8 | ## Setup 9 | ### Obtaining request strings 10 | To obtain the request strings it is a fairly involved process. I would not recommend sharing your individual request strings with other people as they may contain sensitive authentication data in relation to your Steam account. Also, the server's that appear will depend on what DLC is associated with the steam account you use to get the request strings so it is ideal to have the British GT and Intercontinental GT packs installed (though this doesn't seem to happen with the other DLCs). 11 | 1. Install wireshark https://www.wireshark.org/ (Other programs may work but this is what I have used) 12 | 2. Open wireshark and select your main network adapter, the graphs for activity next to the options should give a clue as to which it is 13 | 3. Set the filter to only be requests to Kunos's ACC server ip and press Enter to apply it. 14 | ``` 15 | ip.dst == 144.76.81.131 16 | ``` 17 | 4. Start ACC, go to the server list and then close the game. 18 | 19 | 5. The "auth string" is fairly easy to identify, it should be in a `WebSocket Text [FIN] [MASKED]` request after the HTTP `GET /kson809/` and the data will start with hex values separated by vertical lines. Select it and right click the "Data" section and copy it as printable text. 20 | 21 | 6. The "query string" can be found in a `WebSocket Binary [FIN] [MASKED]` request. It will contain some IP addresses in the ASCII preview below, however there are a few like this and the one that you need specifically will be different as when you select it, the whole preview will be highlighted as data. For this, right click on the data and copy it as a hex stream. 22 | 23 | ### Run the development server 24 | 1. Install MongoDB 25 | There are multiple ways to do this, I recommend using the docker image with docker desktop. You could also use MongoDB Atlas for free cloud hosting. 26 | 2. Install nodejs/ npm 27 | 3. Install the dependencies 28 | ``` 29 | npm install --dev 30 | ``` 31 | 4. Set up the env. This can either be done by setting the environment variables directly or by creating a .env file in the server's root folder. 32 | ``` 33 | DB_URL="mongodb://" 34 | QUERYSTRING="" 35 | AUTHSTRING="" 36 | ``` 37 | 5. Run the server 38 | ``` 39 | npm run dev 40 | ``` 41 | 42 | ### Deployment 43 | As this is a Next.js app, one of the easiest ways to deploy is by using [Vercel](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) although it should be compatible with most serverless providers. You can then just provide it the same environment variables used for development and it should work (although the Mongo Atlas integration is natively supported on Vercel). -------------------------------------------------------------------------------- /pages/api/servers.ts: -------------------------------------------------------------------------------- 1 | import { ServerType } from "$utils/db"; 2 | import models from "$utils/db"; 3 | import getServers from "$utils/getServers"; 4 | import type { NextApiResponse, NextApiRequest } from "next"; 5 | import { z } from "zod"; 6 | let lastCheck = 0; 7 | let getServerPromise: Promise; 8 | 9 | const filterInput = z 10 | .object({ 11 | favourites: z 12 | .string() 13 | .regex(/.*/) 14 | .transform((a) => { 15 | return a.split(",") || []; 16 | }) 17 | .optional(), 18 | showEmpty: z 19 | .string() 20 | .regex(/.*/) 21 | .transform((a) => { 22 | return a == "true"; 23 | }) 24 | .optional() 25 | .default("false"), 26 | showFull: z 27 | .string() 28 | .regex(/.*/) 29 | .transform((a) => { 30 | return a == "true"; 31 | }) 32 | .optional() 33 | .default("true"), 34 | favouritesOnly: z 35 | .string() 36 | .regex(/.*/) 37 | .transform((a) => { 38 | return a == "true"; 39 | }) 40 | .optional() 41 | .default("false"), 42 | class: z 43 | .string() 44 | .regex(/.*/) 45 | .transform((a) => { 46 | return a.split(",") || []; 47 | }) 48 | .optional(), 49 | dlc: z 50 | .string() 51 | .regex(/.*/) 52 | .transform((a) => { 53 | return a.split(",") || []; 54 | }) 55 | .optional(), 56 | sessions: z 57 | .string() 58 | .regex(/.*/) 59 | .transform((a) => { 60 | return a.split(",") || []; 61 | }) 62 | .optional(), 63 | search: z.string().optional(), 64 | min_sa: z 65 | .number() 66 | .or(z.string().regex(/\d+/).transform(Number)) 67 | .optional() 68 | .default(0), 69 | max_sa: z 70 | .number() 71 | .or(z.string().regex(/\d+/).transform(Number)) 72 | .optional() 73 | .default(99), 74 | min_tm: z 75 | .number() 76 | .or(z.string().regex(/\d+/).transform(Number)) 77 | .optional() 78 | .default(0), 79 | max_tm: z 80 | .number() 81 | .or(z.string().regex(/\d+/).transform(Number)) 82 | .optional() 83 | .default(3), 84 | min_drivers: z 85 | .number() 86 | .or(z.string().regex(/\d+/).transform(Number)) 87 | .optional() 88 | .default(0), 89 | max_drivers: z 90 | .number() 91 | .or(z.string().regex(/\d+/).transform(Number)) 92 | .optional() 93 | .default(99), 94 | }) 95 | .nullish(); 96 | 97 | export type filterType = z.infer; 98 | 99 | export default async function handler( 100 | req: NextApiRequest, 101 | res: NextApiResponse<{servers: Array, status: String}> 102 | ) { 103 | // This is probably bad practice but I don't think it matters too much 104 | const urlObj = new URL(`http://localhost` + (req.url ?? "/api/servers")); 105 | let rawData = req.url ? Object.fromEntries(urlObj.searchParams) : {}; 106 | const input = filterInput.parse(rawData); 107 | if (Date.now() - lastCheck > 2 * 60 * 1000 || await getServerPromise === "offline") { 108 | lastCheck = Date.now(); 109 | getServerPromise = getServers(); 110 | } 111 | 112 | await getServerPromise; 113 | 114 | const queryData = { 115 | ...(input?.favouritesOnly && { 116 | id: { 117 | $in: input.favourites, 118 | }, 119 | }), 120 | ...(input?.search && { 121 | name: { 122 | $regex: input.search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), 123 | $options: "i", 124 | }, 125 | }), 126 | ...(input?.dlc && { 127 | "track.dlc": { 128 | $in: input.dlc, 129 | }, 130 | }), 131 | ...(input?.class && { 132 | class: { 133 | $in: input.class, 134 | }, 135 | }), 136 | ...(input?.sessions && { 137 | sessions: { 138 | $elemMatch: { 139 | type: { 140 | $in: input.sessions, 141 | }, 142 | active: true, 143 | }, 144 | }, 145 | }), 146 | "requirements.trackMedals": { 147 | $gte: input?.min_tm ?? 0, 148 | $lte: input?.max_tm ?? 3, 149 | }, 150 | "requirements.safetyRating": { 151 | $gte: input?.min_sa ?? 0, 152 | $lte: input?.max_sa ?? 99, 153 | }, 154 | "drivers.connected": { 155 | $gte: input?.min_drivers ?? 0, 156 | $lte: input?.max_drivers ?? 99, 157 | ...(!input?.showEmpty && { 158 | $ne: 0, 159 | }), 160 | }, 161 | ...(!input?.showFull && { 162 | isFull: false, 163 | }), 164 | }; 165 | let data: Array = await models.Server.aggregate( 166 | [ 167 | { 168 | $match: queryData, 169 | }, 170 | { 171 | $addFields: { 172 | isFavourite: { 173 | $in: ["$id", input?.favourites || []], 174 | }, 175 | }, 176 | }, 177 | { 178 | $sort: { 179 | isFavourite: -1, 180 | "drivers.connected": -1, 181 | }, 182 | }, 183 | ], 184 | { allowDiskUse: true } 185 | ); 186 | res.status(200).json({ 187 | servers: data, 188 | status: await getServerPromise 189 | }); 190 | } 191 | -------------------------------------------------------------------------------- /components/server.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { 4 | faCopy, 5 | faStar, 6 | faCloudMoonRain, 7 | faCloudShowersHeavy, 8 | faMoon, 9 | } from "@fortawesome/free-solid-svg-icons"; 10 | import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons"; 11 | import styles from "./server.module.scss"; 12 | import { useEffect, useContext, useState } from "react"; 13 | import { ServerType } from "$utils/db"; 14 | import DataContext from "./dataContext"; 15 | 16 | export const Server = ({ 17 | data, 18 | isRendered, 19 | serverId, 20 | }: { 21 | data: ServerType; 22 | isRendered: Function; 23 | serverId: string; 24 | }) => { 25 | const ctx = useContext(DataContext); 26 | const sessionEls = []; 27 | const [isFavourite, setFavourite] = useState(data.isFavourite); 28 | const split = data.name?.split( 29 | /((?:https:\/\/|http:\/\/)?(?:www\.)?discord\.(?:gg|com\/invite)\/([A-Za-z0-9]*?)(?:\s|$))/gm 30 | ); 31 | 32 | for (const [i, session] of data.sessions.entries()) { 33 | sessionEls.push( 34 |
41 | {session.type}: {session.time} 42 |
43 | ); 44 | } 45 | useEffect(() => { 46 | isRendered(); 47 | isRendered = () => {}; 48 | }); 49 | return ( 50 | 51 | { 54 | navigator.clipboard.writeText(data.name ?? ""); 55 | }} 56 | > 57 | 58 | 59 | {/* */} 60 | 61 |
{ 64 | if (data.isFavourite) { 65 | ctx.removeFavourite(data.id ?? ""); 66 | } else { 67 | ctx.addFavourite(data.id ?? ""); 68 | } 69 | data.isFavourite = !data.isFavourite; 70 | setFavourite(data.isFavourite); 71 | }} 72 | > 73 | {isFavourite ? ( 74 | 75 | ) : ( 76 | 77 | )} 78 |
79 | 80 | 81 | 82 |
83 | {split?.length === 1 84 | ? data.name 85 | : split?.map((txt, i) => { 86 | switch (i % 3) { 87 | case 0: 88 | return txt; 89 | case 1: 90 | return ( 91 | 95 | {txt} 96 | 97 | ); 98 | default: 99 | return null; 100 | } 101 | })} 102 |
103 |
{sessionEls}
104 |
105 | REQUIREMENTS: 106 | 107 | Track Medals: {data.requirements?.trackMedals} 108 | 109 | 110 | Safety Rating: {data.requirements?.safetyRating} 111 | 112 |
113 | 114 | 115 |
116 |
117 | {data.conditions?.rain ? ( 118 | data.conditions?.night ? ( 119 | 120 | ) : ( 121 | 122 | ) 123 | ) : ( 124 | data.conditions?.night && 125 | )} 126 |
127 | 132 |
133 | 134 | 135 |
{data.track?.name}
136 |
142 | {data.class} 143 |
144 | 145 | 146 | Drivers 147 | {`${data.drivers?.connected}/${data.drivers?.max}`} 148 | 149 | 150 | {`${data.country_code} 155 | 156 | {/* */} 157 | 158 | ); 159 | }; 160 | -------------------------------------------------------------------------------- /components/serverList.tsx: -------------------------------------------------------------------------------- 1 | import { filterType } from "$api/servers"; 2 | import { ServerType } from "$utils/db"; 3 | import { 4 | useContext, 5 | useEffect, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import DataContext from "./dataContext"; 10 | import { Server } from "./server"; 11 | import styles from "./serverList.module.scss"; 12 | import { event } from "nextjs-google-analytics"; 13 | 14 | let currentIndex = 0; 15 | let lastFilters: filterType = null; 16 | 17 | export const ServerList = ({setStatus} : {setStatus: Function}) => { 18 | const ctx = useContext(DataContext); 19 | const containerRef = useRef(null); 20 | const paddingRef = useRef(null); 21 | 22 | const [servers, setServers] = useState | null>(null); 23 | const [loadedData, setLoadedData] = useState | null>([]); 24 | 25 | ctx.refetch = (data: filterType) => { 26 | getServers(data); 27 | }; 28 | 29 | useEffect(() => { 30 | let scrollTimeout: number | null; 31 | let fastScrollTimeout: number | null | undefined; 32 | const container = containerRef.current; 33 | const padding = paddingRef.current; 34 | container?.addEventListener("scroll", checkScroll); 35 | 36 | async function needNewContent() { 37 | if (!container || !padding) { 38 | return; 39 | } 40 | while ( 41 | container.scrollHeight - padding.clientHeight - container.scrollTop < container.clientHeight + container.clientHeight * 2 && 42 | servers !== null && servers.length > currentIndex 43 | ) { 44 | await addServer(); 45 | } 46 | padding.style.height = "0px"; 47 | } 48 | 49 | async function addServer() { 50 | const serverIndex = currentIndex; 51 | currentIndex += 1; 52 | if ( 53 | servers && 54 | container && 55 | padding && 56 | servers.length - serverIndex > 0 57 | ) { 58 | const serverData = servers[serverIndex]; 59 | await new Promise(async (resolve) => { 60 | setLoadedData((oldData) => [ 61 | ...oldData ? oldData : [], 62 | , 68 | ]); 69 | }); 70 | padding.style.height = `${ 71 | (container.firstChild?.firstChild as HTMLElement) 72 | .clientHeight * 73 | (servers.length - serverIndex) - 74 | container.clientHeight * 2 75 | }px`; 76 | } else { 77 | setTimeout(addServer, 5000); 78 | } 79 | } 80 | 81 | function checkScroll() { 82 | if (!container || !padding) { 83 | return; 84 | } 85 | 86 | if ( 87 | servers && 88 | servers.length - currentIndex > 0 && 89 | scrollTimeout == null 90 | ) { 91 | if ( 92 | container.scrollHeight - 93 | padding.clientHeight - 94 | container.scrollTop < 95 | 500 96 | ) { 97 | if (fastScrollTimeout != null) { 98 | window.clearTimeout(fastScrollTimeout); 99 | } 100 | let lastPos = 101 | container.scrollHeight - 102 | padding.clientHeight - 103 | container.scrollTop; 104 | 105 | fastScrollTimeout = window.setTimeout(() => { 106 | if (!servers || !container || !padding) { 107 | return; 108 | } 109 | 110 | let newPos = 111 | container.scrollHeight - 112 | padding.clientHeight - 113 | container.scrollTop; 114 | if (lastPos - newPos < 500 && lastPos - newPos > -500) { 115 | needNewContent(); 116 | } 117 | fastScrollTimeout = null; 118 | }, 500); 119 | } else { 120 | scrollTimeout = window.setTimeout(() => { 121 | if (!servers || !container) { 122 | return; 123 | } 124 | needNewContent(); 125 | scrollTimeout = null; 126 | }, 200); 127 | } 128 | } 129 | } 130 | 131 | if(servers){ 132 | needNewContent(); 133 | } 134 | 135 | return ()=>{ 136 | container?.removeEventListener("scroll", checkScroll); 137 | } 138 | }, [servers]); 139 | 140 | async function getServers(filters: filterType = null) { 141 | event("get_servers", { 142 | category: "general", 143 | ...(filters && "search" in filters && {label: filters.search}) 144 | }); 145 | if(!filters){ 146 | if(lastFilters){ 147 | filters = lastFilters 148 | } 149 | } else { 150 | lastFilters = {...filters, ...{search: ""}}; 151 | } 152 | currentIndex = 0; 153 | setLoadedData(null); 154 | filters = { 155 | ...filters, 156 | ...{favourites: JSON.parse(localStorage.getItem("favourites") || "[]") as Array} 157 | } as filterType; 158 | const urlData = new URLSearchParams(filters as {[key: string]: any}).toString(); 159 | const data = (await ( 160 | await fetch("/api/servers?" + urlData) 161 | ).json()) as {servers: Array, status: string}; 162 | if(data.status){ 163 | setStatus(data.status); 164 | } 165 | if(!data.servers){ 166 | return 167 | } 168 | if(data.servers.length === 0){ 169 | setLoadedData([]); 170 | } 171 | setServers(data.servers); 172 | } 173 | 174 | return ( 175 |
176 | 177 | 178 | {loadedData} 179 | 180 | 181 |
182 |
185 |
186 |
187 |
188 |
189 |
190 |
191 | Getting kunos servers 192 |
193 |
194 | ); 195 | }; 196 | -------------------------------------------------------------------------------- /utils/getServers.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "crypto"; 2 | import WebSocket from "ws"; 3 | import models from "./db"; 4 | import geoip from "geoip-country"; 5 | 6 | const trackData: { 7 | [key: string]: { 8 | name: string; 9 | dlc?: string; 10 | image?: string; 11 | }; 12 | } = { 13 | barcelona: { 14 | name: "Barcelona Grand Prix Circuit", 15 | image: "track-Barcelona", 16 | }, 17 | mount_panorama: { 18 | name: "Bathurst - Mount Panorama Circuit", 19 | dlc: "icgt", 20 | image: "int_gt_pack-18", 21 | }, 22 | brands_hatch: { 23 | name: "Brands Hatch", 24 | image: "track-Brands-Hatch", 25 | }, 26 | donington: { 27 | name: "Donington Park", 28 | dlc: "bgt", 29 | image: "9-2", 30 | }, 31 | hungaroring: { 32 | name: "Hungaroring", 33 | }, 34 | imola: { 35 | name: "Imola", 36 | dlc: "gtwc", 37 | image: "track-imola", 38 | }, 39 | kyalami: { 40 | name: "Kyalami", 41 | dlc: "icgt", 42 | }, 43 | valencia: { 44 | name: "Valencia", 45 | dlc: "GTWC-2023", 46 | }, 47 | laguna_seca: { 48 | name: "Laguna Seca", 49 | dlc: "icgt", 50 | image: "int_gt_pack-2", 51 | }, 52 | misano: { 53 | name: "Misano", 54 | }, 55 | monza: { 56 | name: "Monza", 57 | }, 58 | nurburgring: { 59 | name: "Nurburgring", 60 | }, 61 | oulton_park: { 62 | name: "Oulton Park", 63 | dlc: "bgt", 64 | image: "12-3", 65 | }, 66 | paul_ricard: { 67 | name: "Paul Ricard", 68 | image: "track-Paul-Ricard", 69 | }, 70 | silverstone: { 71 | name: "Silverstone", 72 | }, 73 | snetterton: { 74 | name: "Snetterton 300", 75 | dlc: "bgt", 76 | }, 77 | spa: { 78 | name: "Spa-Francorchamps", 79 | }, 80 | suzuka: { 81 | name: "Suzuka", 82 | dlc: "icgt", 83 | image: "int_gt_pack-3", 84 | }, 85 | zandvoort: { 86 | name: "Zandvoort", 87 | }, 88 | zolder: { 89 | name: "Zolder", 90 | }, 91 | watkins_glen: { 92 | name: "Watkins Glen", 93 | dlc: "atp", 94 | image: "9-2-3", 95 | }, 96 | cota: { 97 | name: "Circuit of the Americas", 98 | dlc: "atp", 99 | image: "3-3", 100 | }, 101 | indianapolis: { 102 | name: "Indianapolis Motor Speedway", 103 | dlc: "atp", 104 | image: "jp", 105 | }, 106 | nurburgring_24h: { 107 | name: "Nurburgring 24h", 108 | dlc: "nurb-24", 109 | }, 110 | red_bull_ring: { 111 | name: "Red Bull Ring", 112 | dlc: "gt2", 113 | }, 114 | }; 115 | 116 | function getTrack(id: string) { 117 | // todo: match legacy naming to current spec, see: https://www.acc-wiki.info/wiki/Racetracks_Overview 118 | if (id in trackData) { 119 | const track = trackData[id]; 120 | if (!track.dlc) { 121 | track.dlc = "base"; 122 | } 123 | if (!track.image) { 124 | track.image = `track-${track.name}`; 125 | } 126 | return { ...track, id }; 127 | } else { 128 | console.log( 129 | `New track: ${id}, please create an issue at https://github.com/reesvarney/acc-server-browser-web to add it` 130 | ); 131 | } 132 | return { 133 | name: id, 134 | dlc: "base", 135 | id, 136 | image: "", 137 | }; 138 | } 139 | 140 | function getServers(): Promise { 141 | return new Promise((resolve) => { 142 | let currentIndex = 0; 143 | let rawData: Buffer; 144 | let got_response = false; 145 | 146 | console.log("Getting server list"); 147 | const ws = new WebSocket("ws://809a.assettocorsa.net:80/kson809", "ws", { 148 | protocolVersion: 13, 149 | headers: { 150 | Pragma: "no-cache", 151 | "Sec-WebSocket-Protocol": "ws", 152 | "sec-websocket-key": randomBytes(16).toString("base64"), 153 | "Sec-WebSocket-Extensions": 154 | "permessage-deflate; client_max_window_bits", 155 | }, 156 | }); 157 | 158 | const queryString = process.env.QUERYSTRING ?? ""; 159 | const authString = process.env.AUTHSTRING ?? ""; 160 | ws.on("open", () => { 161 | console.log("Websocket connection established"); 162 | ws.send(authString); 163 | const hex = Buffer.from(queryString, "hex"); 164 | ws.send(hex); 165 | console.log("Sent query string"); 166 | setTimeout(() => { 167 | if (!got_response) { 168 | ws.close(); 169 | resolve("offline"); 170 | console.log("No response from server"); 171 | } 172 | }, 1000); 173 | }); 174 | 175 | ws.on("message", (data: Buffer) => { 176 | console.log("Received binary data"); 177 | rawData = data; 178 | got_response = true; 179 | cleanData(); 180 | ws.close(); 181 | }); 182 | 183 | ws.on("error", (err: string) => { 184 | resolve("offline"); 185 | console.log("offline", err); 186 | }); 187 | 188 | ws.on("unexpected-response", (err: string) => { 189 | console.log(err); 190 | }); 191 | 192 | function readData(length: number) { 193 | const data = rawData.subarray(currentIndex, currentIndex + length); 194 | currentIndex += length; 195 | return data; 196 | } 197 | 198 | function readNumber() { 199 | const data = rawData.readUInt8(currentIndex); 200 | currentIndex += 1; 201 | return data; 202 | } 203 | 204 | function readLastBitBoolean() { 205 | return readData(1).readInt8() ? true : false; 206 | } 207 | 208 | function readFirstBitBoolean() { 209 | return readData(1)[0] === 0x80; 210 | } 211 | 212 | function getSessionType() { 213 | const data = readData(1)[0]; 214 | if (data === 0x0a) return "Race"; 215 | if (data === 0x04) return "Qualifying"; 216 | if (data === 0x00) return "Practice"; 217 | console.log(`Unknown session type: ${data}`); 218 | return "Unknown"; 219 | } 220 | 221 | function getVehicleClass() { 222 | const data = readData(1)[0]; 223 | if (data === 0xfa) return "Mixed"; 224 | if (data === 0x00) return "GT3"; 225 | if (data === 0x07) return "GT4"; 226 | if (data === 0xf9) return "GTC"; 227 | if (data === 0x0c) return "TCX"; 228 | if (data === 0x0b) return "GT2"; 229 | console.log(`Unknown vehicle class: ${data}`); 230 | return "Unknown"; 231 | } 232 | 233 | function readString(length: number) { 234 | return readData(length).toString("utf-8"); 235 | } 236 | 237 | function readDynamicString() { 238 | const nextLength = readNumber(); 239 | const compstring = readString(nextLength); 240 | return compstring; 241 | } 242 | 243 | async function cleanData() { 244 | currentIndex = 122; 245 | const start = Date.now(); 246 | const bulk = models.Server.collection.initializeUnorderedBulkOp(); 247 | const ids = []; 248 | while (rawData.length - currentIndex > 3) { 249 | // ip 250 | const misc = []; 251 | const ip = readDynamicString(); 252 | if (!/^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(ip)) { 253 | continue; 254 | } 255 | // unknown? 256 | const port = { 257 | tcp: readNumber() + readNumber() * 256, 258 | udp: readNumber() + readNumber() * 256, 259 | }; 260 | 261 | const id = `${ip}:${port.tcp}`; 262 | misc.push(readData(1)); 263 | const track = getTrack(readDynamicString()); 264 | const name = readDynamicString(); 265 | misc.push(readData(2)); 266 | const vehicleClass = getVehicleClass(); 267 | misc.push(readData(10)); 268 | const hotjoin = readLastBitBoolean(); 269 | misc.push(readData(1)); 270 | 271 | const numOfSessions = readNumber(); 272 | const sessions = []; 273 | for (let i = 0; i < numOfSessions; i++) { 274 | sessions.push({ 275 | type: getSessionType(), 276 | time: readNumber() + readNumber() * 256, 277 | active: false, 278 | }); 279 | } 280 | const drivers = { 281 | max: readNumber(), 282 | connected: readNumber(), 283 | }; 284 | misc.push(readData(3)); 285 | const conditions = { 286 | rain: readFirstBitBoolean(), 287 | night: false, 288 | variability: 0, 289 | }; 290 | // This has got something to do with cloud cover however it only seems to affect it when rain is also enabled? 291 | // Don't know how rain intensity is communicated when it seems to be true/ false though maybe we're looking at the wrong value 292 | // Maybe some of the data is for a forecast? 293 | misc.push(readData(1)); 294 | conditions.night = readLastBitBoolean(); 295 | conditions.variability = readNumber(); 296 | 297 | const requirements = { 298 | trackMedals: readNumber(), 299 | safetyRating: readNumber(), 300 | }; 301 | if (requirements.safetyRating == 255) { 302 | requirements.safetyRating = 0; 303 | } 304 | misc.push(readData(8)); 305 | const currentSession = readNumber(); 306 | if (currentSession < sessions.length) { 307 | sessions[currentSession].active = true; 308 | } 309 | ids.push(id); 310 | let country_code = "un"; 311 | try { 312 | const geo = await geoip.lookup(ip || ""); 313 | if (!geo) throw new Error("Could not lookup IP"); 314 | country_code = geo.country.toLowerCase(); 315 | } catch (err) { 316 | console.log(err); 317 | } 318 | 319 | bulk 320 | .find({ id }) 321 | .upsert() 322 | .updateOne({ 323 | $set: { 324 | ip, 325 | id, 326 | port, 327 | track, 328 | name, 329 | class: vehicleClass, 330 | hotjoin, 331 | numOfSessions, 332 | sessions, 333 | drivers, 334 | conditions, 335 | requirements, 336 | currentSession, 337 | isFull: drivers.max == drivers.connected, 338 | country_code, 339 | }, 340 | }); 341 | } 342 | const timeTaken_1 = Date.now() - start; 343 | console.log(`Converted servers in ${timeTaken_1} ms`); 344 | console.log(`Converted servers to JSON objects`); 345 | bulk 346 | .find({ 347 | id: { 348 | $nin: ids, 349 | }, 350 | }) 351 | .delete(); 352 | await bulk.execute(); 353 | const timeTaken_2 = Date.now() - start; 354 | console.log(`Updated ${ids.length} servers in ${timeTaken_2} ms`); 355 | resolve("online"); 356 | } 357 | }); 358 | } 359 | 360 | export default getServers; 361 | -------------------------------------------------------------------------------- /components/filters.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useContext, useEffect, useState } from "react"; 2 | import styles from "./filters.module.scss"; 3 | import DataContext from "./dataContext"; 4 | import { filterType } from "$pages/api/servers"; 5 | export const Filters = () => { 6 | const [filtersVisible, setFiltersVisible] = useState(false); 7 | const [filterData, setFilterData] = useState(null); 8 | 9 | const ctx = useContext(DataContext); 10 | function showFilters() { 11 | setFiltersVisible(true); 12 | } 13 | 14 | function hideFilters() { 15 | setFiltersVisible(false); 16 | } 17 | 18 | function updateServers(e: FormEvent) { 19 | e.preventDefault(); 20 | const data = new FormData(e.currentTarget); 21 | const searchVal = data.get("search") as string; 22 | const oldData = JSON.parse(localStorage.getItem("filters") ?? "{}"); 23 | const newData = { 24 | min_sa: Number(data.get("min_sa") ?? oldData.min_sa ?? 0), 25 | max_sa: Number(data.get("max_sa") ?? oldData.max_sa ?? 99), 26 | min_drivers: Number(data.get("min_cd") ?? oldData.min_drivers ?? 0), 27 | max_drivers: Number(data.get("max_cd") ?? oldData.max_drivers ?? 99), 28 | min_tm: Number(data.get("min_tm") ?? oldData.min_tm ?? 0), 29 | max_tm: Number(data.get("max_tm") ?? oldData.max_tm ?? 3), 30 | showEmpty: data.get("show_empty") == "on" ?? oldData.showEmpty ?? false, 31 | showFull: data.get("show_full") == "on" ?? oldData.showFull ?? true, 32 | class: (data.getAll("class") as Array) ?? oldData.class ?? [], 33 | dlc: (data.getAll("dlc") as Array) ?? oldData.dlc ?? [], 34 | sessions: 35 | (data.getAll("session") as Array) ?? oldData.sessions ?? [], 36 | favouritesOnly: 37 | data.get("favourites_only") == "on" ?? oldData.favouritesOnly ?? false, 38 | ...(searchVal && searchVal !== "" && { search: searchVal }), 39 | }; 40 | setFilterData(newData); 41 | } 42 | 43 | ctx.addFavourite = (id) => { 44 | const favourites = JSON.parse( 45 | localStorage.getItem("favourites") || "[]" 46 | ) as Array; 47 | localStorage.setItem("favourites", JSON.stringify([...favourites, id])); 48 | }; 49 | 50 | ctx.removeFavourite = (id) => { 51 | const favourites = JSON.parse( 52 | localStorage.getItem("favourites") || "[]" 53 | ) as Array; 54 | localStorage.setItem( 55 | "favourites", 56 | JSON.stringify(favourites.filter((a) => a !== id)) 57 | ); 58 | }; 59 | 60 | useEffect(() => { 61 | if (filterData) { 62 | localStorage.setItem("filters", JSON.stringify(filterData)); 63 | ctx.refetch(filterData); 64 | } else { 65 | // set defaults or load from localStorage 66 | const data = JSON.parse(localStorage.getItem("filters") ?? "{}"); 67 | const setData = { 68 | min_sa: data["min_sa"] ?? 0, 69 | max_sa: data["max_sa"] ?? 99, 70 | min_drivers: data["min_cd"] ?? 0, 71 | max_drivers: data["max_cd"] ?? 99, 72 | min_tm: data["min_tm"] ?? 0, 73 | max_tm: data["max_tm"] ?? 3, 74 | showEmpty: data["showEmpty"] ?? false ? true : false, 75 | showFull: data["showFull"] ?? true ? true : false, 76 | class: data["class"] ?? ["mixed", "gt3", "gt4", "gtc", "tcx", "gt2"], 77 | dlc: data["dlc"] ?? [ 78 | "base", 79 | "icgt", 80 | "gtwc", 81 | "bgt", 82 | "atp", 83 | "gt2", 84 | "nurb-24", 85 | ], 86 | sessions: data["session"] ?? ["race", "qualifying", "practice"], 87 | favouritesOnly: data["favouritesOnly"] ?? false ? true : false, 88 | }; 89 | setFilterData(setData); 90 | } 91 | }, [filterData]); 92 | 93 | return ( 94 |
95 |
96 | 97 | {!filtersVisible ? ( 98 | 101 | ) : null} 102 |
103 |
109 |
110 | SA 111 |
112 | 113 | 121 |
122 |
123 | 124 | 132 |
133 |
134 |
135 |
Track Medals
136 |
137 | 138 | 146 |
147 |
148 | 149 | 157 |
158 |
159 |
160 | Drivers 161 |
162 | 163 | 171 |
172 |
173 | 174 | 182 |
183 |
184 | 185 |
186 | DLC 187 |
188 | 189 | 196 |
197 |
198 | 199 | 206 |
207 |
208 | 209 | 216 |
217 |
218 | 219 | 226 |
227 |
228 | 229 | 236 |
237 |
238 | 239 | 246 |
247 |
248 | 249 | 256 |
257 |
258 | 259 | 266 |
267 |
268 |
269 | Class 270 |
271 | 272 | 279 |
280 |
281 | 282 | 289 |
290 |
291 | 292 | 299 |
300 |
301 | 302 | 309 |
310 |
311 | 312 | 313 | 320 |
321 |
322 | 323 | 324 | 331 |
332 |
333 | 334 |
335 | Current Session 336 |
337 | 338 | 339 | 348 |
349 |
350 | 351 | 360 |
361 |
362 | 363 | 370 |
371 |
372 | 373 |
374 | Misc 375 |
376 | 377 | 383 |
384 |
385 | 386 | 392 |
393 |
394 | 395 | 401 |
402 | {/* Hidden */} 403 | `"${a}"`)}]` ?? "[]" 409 | } 410 | /> 411 |
412 | 413 |
414 | 417 | 420 |
421 |
422 |
423 | ); 424 | }; 425 | --------------------------------------------------------------------------------