├── parser ├── .gitignore ├── default.nix └── parse_matches.sh ├── .gitattributes ├── .gitignore ├── gh_images ├── main-page.png └── player-page-cropped.png ├── frontend ├── .gitignore ├── resources │ ├── images │ │ └── players │ │ │ └── _missing.png │ ├── vendor │ │ └── scripts │ │ │ ├── tablesort.number.min.js │ │ │ ├── tablesort.min.js │ │ │ └── gray.js │ ├── styles │ │ ├── tablesort.css │ │ ├── player.css │ │ └── base.css │ ├── pages │ │ └── player.html │ └── scripts │ │ ├── player.js │ │ └── index.js ├── default.nix ├── index.html └── generate_lookup_data.sh ├── .dockerignore ├── fetcher ├── .gitignore ├── .prettierrc ├── default.nix ├── check_api_credentials.ts ├── package.json ├── query_player.ts ├── fetch_matches.ts └── package-lock.json ├── deploy ├── default.nix └── deploy.sh ├── .editorconfig ├── default.nix ├── config ├── players.json.example └── env.example ├── Dockerfile ├── LICENSE ├── run_and_deploy.sh ├── Makefile └── README.md /parser/.gitignore: -------------------------------------------------------------------------------- 1 | /data 2 | !/data/.gitkeep 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config/env 2 | /config/players.json 3 | 4 | .data 5 | !.data/**/.gitkeep 6 | -------------------------------------------------------------------------------- /gh_images/main-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Swift/cod-stats/HEAD/gh_images/main-page.png -------------------------------------------------------------------------------- /gh_images/player-page-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Swift/cod-stats/HEAD/gh_images/player-page-cropped.png -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /data 2 | !/data/.gitkeep 3 | /resources/images/players/* 4 | !/resources/images/players/_missing.png 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .dockerignore 3 | Dockerfile 4 | 5 | .data 6 | 7 | /fetcher/node_modules 8 | /deploy/**/node_modules 9 | -------------------------------------------------------------------------------- /fetcher/.gitignore: -------------------------------------------------------------------------------- 1 | /out 2 | /node_modules 3 | /fetch_matches.js 4 | /query_player.js 5 | /check_api_credentials.js 6 | /data/out 7 | -------------------------------------------------------------------------------- /fetcher/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "printWidth": 120, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /frontend/resources/images/players/_missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/J-Swift/cod-stats/HEAD/frontend/resources/images/players/_missing.png -------------------------------------------------------------------------------- /deploy/default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation rec { 4 | name = "env"; 5 | env = buildEnv { name = name; paths = buildInputs; }; 6 | buildInputs = [ 7 | bash 8 | 9 | awscli 10 | ]; 11 | } 12 | -------------------------------------------------------------------------------- /fetcher/default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation rec { 4 | name = "env"; 5 | env = buildEnv { name = name; paths = buildInputs; }; 6 | buildInputs = [ 7 | bash 8 | 9 | nodejs-12_x 10 | ]; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /frontend/default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation rec { 4 | name = "env"; 5 | env = buildEnv { name = name; paths = buildInputs; }; 6 | buildInputs = [ 7 | sqlite 8 | jq 9 | awscli 10 | ]; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /parser/default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation rec { 4 | name = "env"; 5 | env = buildEnv { name = name; paths = buildInputs; }; 6 | buildInputs = [ 7 | bash 8 | 9 | sqlite 10 | jq 11 | ]; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [Makefile] 11 | indent_style = tab 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation rec { 4 | name = "env"; 5 | env = buildEnv { name = name; paths = buildInputs; }; 6 | buildInputs = [ 7 | bash 8 | 9 | nodejs-12_x 10 | sqlite 11 | jq 12 | awscli 13 | ]; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/resources/vendor/scripts/tablesort.number.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * tablesort v5.2.1 (2020-06-02) 3 | * http://tristen.ca/tablesort/demo/ 4 | * Copyright (c) 2020 ; Licensed MIT 5 | */ 6 | !function(){var a=function(a){return a.replace(/[^\-?0-9.]/g,"")},b=function(a,b){return a=parseFloat(a),b=parseFloat(b),a=isNaN(a)?0:a,b=isNaN(b)?0:b,a-b};Tablesort.extend("number",function(a){return a.match(/^[-+]?[£\x24Û¢´€]?\d+\s*([,\.]\d{0,2})/)||a.match(/^[-+]?\d+\s*([,\.]\d{0,2})?[£\x24Û¢´€]/)||a.match(/^[-+]?(\d)*-?([,\.]){0,1}-?(\d)+([E,e][\-+][\d]+)?%?$/)},function(c,d){return c=a(c),d=a(d),b(d,c)})}(); -------------------------------------------------------------------------------- /fetcher/check_api_credentials.ts: -------------------------------------------------------------------------------- 1 | const API = require('call-of-duty-api')(); 2 | 3 | const SSO = process.env.COD_SSO; 4 | 5 | // DO WORK SON 6 | 7 | /* 8 | * CLI handler 9 | */ 10 | 11 | (async () => { 12 | if (!SSO) { 13 | console.error('Must set envvar [COD_SSO]'); 14 | process.exit(1); 15 | } 16 | 17 | try { 18 | await API.loginWithSSO(SSO); 19 | console.log('Credentials valid.'); 20 | } catch (err) { 21 | console.log('--------------------------------------------------------------------------------'); 22 | console.error('ERROR:'); 23 | console.error(err); 24 | process.exit(1); 25 | } 26 | })(); 27 | -------------------------------------------------------------------------------- /fetcher/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetcher", 3 | "version": "1.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "call-of-duty-api": "^2.1.2" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^14.0.13", 11 | "typescript": "^3.9.2" 12 | }, 13 | "scripts": { 14 | "main": "index.js", 15 | "build": "./node_modules/.bin/tsc fetch_matches.ts", 16 | "query-player": "./node_modules/.bin/tsc query_player.ts && node query_player.js", 17 | "check-credentials": "./node_modules/.bin/tsc check_api_credentials.ts && node check_api_credentials.js" 18 | }, 19 | "author": "", 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /config/players.json.example: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Jimmy", 4 | "isCore": true, 5 | "accounts": [ 6 | { 7 | "activisionPlatform": "battle", 8 | "activisionTag": "JamesSwift#1805", 9 | "unoId": "2391270" 10 | } 11 | ] 12 | }, 13 | { 14 | "name": "Biffle", 15 | "accounts": [ 16 | { 17 | "activisionPlatform": "battle", 18 | "activisionTag": "DiazBiffle#1415", 19 | "unoId": "2741977644267553271" 20 | }, 21 | { 22 | "activisionPlatform": "acti", 23 | "activisionTag": "Biffle#6182623", 24 | "unoId": "2741977644267553271" 25 | } 26 | ] 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /deploy/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly sourcedir="${COD_DATADIR}/frontend/output" 6 | 7 | die() { 8 | local -r msg="${1}" 9 | 10 | echo "ERROR: ${msg}" 11 | exit 1 12 | } 13 | 14 | if [ ! -d "${sourcedir}" ]; then 15 | die "no directory found at [${sourcedir}]" 16 | fi 17 | 18 | if [ -z "${S3_BUCKET_NAME:-}" ]; then 19 | die 'Must set envvar [S3_BUCKET_NAME]' 20 | fi 21 | 22 | if [ -z "${S3_ENDPOINT:-}" ]; then 23 | die 'Must set envvar [S3_ENDPOINT]' 24 | fi 25 | 26 | main() { 27 | pushd "${sourcedir}" > /dev/null 28 | 29 | echo 'uploading to s3...' 30 | aws s3 sync --delete . s3://"${S3_BUCKET_NAME}"/ --endpoint="${S3_ENDPOINT}" 31 | 32 | echo 33 | echo 'Done!' 34 | 35 | popd > /dev/null 36 | } 37 | 38 | main 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN apk add --no-cache --update --upgrade coreutils bash npm nodejs sqlite jq aws-cli rsync \ 4 | && rm -rf /var/cache/apk/* \ 5 | && mkdir /opt/app 6 | WORKDIR /opt/app 7 | 8 | ENV COD_DATADIR=/opt/data 9 | VOLUME /opt/data 10 | 11 | WORKDIR /opt/app/fetcher 12 | COPY fetcher/package.json fetcher/package-lock.json ./ 13 | RUN npm install 14 | COPY fetcher . 15 | RUN npm run-script build 16 | WORKDIR /opt/app 17 | 18 | WORKDIR /opt/app/parser 19 | COPY parser . 20 | # parser work 21 | WORKDIR /opt/app 22 | 23 | WORKDIR /opt/app/frontend 24 | COPY frontend . 25 | # frontend work 26 | WORKDIR /opt/app 27 | 28 | WORKDIR /opt/app/deploy 29 | COPY deploy/deploy.sh . 30 | # deploy work 31 | WORKDIR /opt/app 32 | 33 | COPY config/players.json ./config/ 34 | COPY run_and_deploy.sh ./ 35 | CMD ["/bin/bash", "run_and_deploy.sh"] 36 | -------------------------------------------------------------------------------- /frontend/resources/styles/tablesort.css: -------------------------------------------------------------------------------- 1 | /* NOTE(jpr): I added logic to handled generic inverted column sort */ 2 | 3 | th[role=columnheader]:not(.no-sort) { 4 | cursor: pointer; 5 | } 6 | 7 | th[role=columnheader]:not(.no-sort):after { 8 | content: ''; 9 | float: right; 10 | margin-top: 7px; 11 | border-width: 0 4px 4px; 12 | border-style: solid; 13 | border-color: #FFF transparent; 14 | visibility: hidden; 15 | opacity: 0; 16 | -ms-user-select: none; 17 | -webkit-user-select: none; 18 | -moz-user-select: none; 19 | user-select: none; 20 | } 21 | 22 | .sort-inverted th[role=columnheader]:not(.no-sort):after { 23 | border-bottom: none; 24 | border-width: 4px 4px 0; 25 | } 26 | 27 | th[aria-sort=ascending]:not(.no-sort):after { 28 | transform: rotate(180deg); 29 | } 30 | 31 | th[aria-sort]:not(.no-sort):after { 32 | visibility: visible; 33 | opacity: 0.4; 34 | } 35 | 36 | th[role=columnheader]:not(.no-sort):hover:after { 37 | visibility: visible; 38 | opacity: 1; 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 James Reichley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /run_and_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | print_header() { 6 | local -r msg="${1}" 7 | local -r print_leading_space="${2:-true}" 8 | if $print_leading_space; then 9 | echo 10 | fi 11 | echo '--------------------------------------------------------------------------------' 12 | echo "> ${msg} <" 13 | echo '--------------------------------------------------------------------------------' 14 | } 15 | 16 | get_ts() { 17 | echo $( date +%s%N | cut -b1-13 ) 18 | } 19 | 20 | main() { 21 | local -r start=$( get_ts ) 22 | 23 | print_header 'Pulling latest matches' false 24 | pushd fetcher > /dev/null 25 | node fetch_matches.js 26 | popd > /dev/null 27 | 28 | print_header 'Updating database' 29 | pushd parser > /dev/null 30 | ./parse_matches.sh 31 | popd > /dev/null 32 | 33 | print_header 'Regenerating FE' 34 | pushd frontend > /dev/null 35 | ./generate_lookup_data.sh 36 | popd > /dev/null 37 | 38 | print_header 'Deploying' 39 | pushd deploy > /dev/null 40 | ./deploy.sh 41 | popd > /dev/null 42 | 43 | local -r end=$( get_ts ) 44 | 45 | print_header "Success! Ran in [$(((end-start)/1000))] seconds" 46 | } 47 | 48 | main 49 | -------------------------------------------------------------------------------- /frontend/resources/styles/player.css: -------------------------------------------------------------------------------- 1 | .recent-matches-table { 2 | margin: auto; 3 | margin-top: 1rem; 4 | max-width: 800px; 5 | } 6 | 7 | .profile-container { 8 | margin: auto; 9 | margin-top: 2rem; 10 | display: flex; 11 | flex-flow: column; 12 | align-items: center; 13 | } 14 | 15 | .profile-container--image { 16 | background: white; 17 | border-radius: 9999px; 18 | width: 8rem; 19 | height: 8rem; 20 | margin-bottom: 1rem; 21 | } 22 | 23 | .games-table-container .gulag-win { 24 | color: rgb(27, 139, 61); 25 | } 26 | 27 | .games-table-container .gulag-loss { 28 | color: rgb(252, 72, 72); 29 | } 30 | 31 | .player-stats { 32 | margin-top: 1rem; 33 | display: flex; 34 | flex-flow: row wrap; 35 | justify-content: space-evenly; 36 | } 37 | 38 | .player-stats--card { 39 | margin-top: 1rem; 40 | } 41 | 42 | .player-stats--card-section-container { 43 | display: flex; 44 | flex-flow: row nowrap; 45 | justify-content: space-evenly; 46 | align-items: flex-end; 47 | 48 | text-align: center; 49 | } 50 | 51 | .player-stats--card-title { 52 | margin-bottom: 0.5rem; 53 | } 54 | 55 | .player-stats--card-section-name { 56 | padding: 1rem 0.5rem 0.25rem 0.5rem; 57 | } 58 | 59 | .player-stats--card-section-value { 60 | font-size: 1.2rem; 61 | padding: 0rem 0.5rem; 62 | } 63 | -------------------------------------------------------------------------------- /config/env.example: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Required 3 | ################################################################################ 4 | 5 | # This can be mostly anything you want as long as it doesnt collide with another local image tag name 6 | DOCKER_IMG_TAG = 7 | 8 | # These can be generated at https://console.aws.amazon.com/iam/home#/security_credentials 9 | AWS_ACCESS_KEY_ID = 10 | AWS_SECRET_ACCESS_KEY = 11 | # e.g. us-east-1 or us-west-2 12 | AWS_REGION = 13 | # NOTE(jpr): this has to be globally unique, and it will be part of the public url that gets used 14 | AWS_S3_BUCKET_NAME = 15 | 16 | # This is your hosting provider. Either amazonaws.com or digitaloceanspaces.com 17 | HOST_PROVIDER = amazonaws.com 18 | # For AWS use s3. For DigitalOcean use $(AWS_REGION) 19 | HOST_REGION = s3 20 | # HOST_REGION = $(AWS_REGION) 21 | 22 | # These are your activision account credentials from my.callofduty.com 23 | # The cookie value of ACT_SSO_COOKIE 24 | COD_API_SSO = 25 | 26 | ################################################################################ 27 | # Optional 28 | ################################################################################ 29 | 30 | # NOTE(jpr): this is not needed, it is only for advanced AWS deployments 31 | AWS_ECR_URL = 32 | -------------------------------------------------------------------------------- /frontend/resources/pages/player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 26 | <- Back to dashboard 27 |
28 | 29 |

Loading...

30 |
31 |
32 |
33 |
34 |
35 |

Loading recent games...

36 |
37 |
38 |
39 |

Loading recent sessions...

40 |
41 |
42 | 43 |   44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | Compare: 26 | 29 | 32 | 33 |
34 |
35 |
36 | Select an option from the dropdowns above 37 |
38 |
39 | 40 |
41 |

Last Sessions

42 |
43 |
44 | 45 |
46 |

Recent Matches

47 |
48 |
49 | 50 |
51 |

Single Game Records

52 |
53 |
54 | 55 |
56 |

Lifetime Records

57 |
58 |
59 | 60 |
61 |

Team Leaderboards

62 |
63 |
64 | 65 | Last Updated: loading... 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /frontend/resources/vendor/scripts/tablesort.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * tablesort v5.2.1 (2020-06-02) 3 | * http://tristen.ca/tablesort/demo/ 4 | * Copyright (c) 2020 ; Licensed MIT 5 | */ 6 | !function(){function a(b,c){if(!(this instanceof a))return new a(b,c);if(!b||"TABLE"!==b.tagName)throw new Error("Element must be a table");this.init(b,c||{})}var b=[],c=function(a){var b;return window.CustomEvent&&"function"==typeof window.CustomEvent?b=new CustomEvent(a):(b=document.createEvent("CustomEvent"),b.initCustomEvent(a,!1,!1,void 0)),b},d=function(a){return a.getAttribute("data-sort")||a.textContent||a.innerText||""},e=function(a,b){return a=a.trim().toLowerCase(),b=b.trim().toLowerCase(),a===b?0:a0)if(a.tHead&&a.tHead.rows.length>0){for(e=0;e0&&n.push(m),o++;if(!n)return}for(o=0;o = { status: 'ok'; results: T }; 9 | type Result = ResultOK | ResultError; 10 | 11 | // DO WORK SON 12 | 13 | async function loginIfNeeded() { 14 | if (!API.isLoggedIn()) { 15 | await API.loginWithSSO(SSO); 16 | } 17 | } 18 | 19 | function isResultError(e: ResultError | any): e is ResultError { 20 | return (e as ResultError).status === 'error'; 21 | } 22 | 23 | async function searchTag(username: string, limit: number = 10) { 24 | let res = await API.FuzzySearch(username, 'all'); 25 | console.log(`> found [${res.length}] results...`); 26 | if (res.length > limit) { 27 | console.log(`> limiting to [${limit}]`); 28 | res = res.slice(0, limit); 29 | } 30 | console.log(); 31 | 32 | const allStatPs = res.map(it => { 33 | const platform = it.platform.toLowerCase() == 'steam' ? 'battle' : it.platform; 34 | return API.MWwzstats(it.username, platform) 35 | .then(stats => { 36 | return { status: 'ok', results: stats.lifetime.mode.br.properties }; 37 | }) 38 | .catch(error => { 39 | return { status: 'error', error }; 40 | }); 41 | }); 42 | const allStats: Result[] = await Promise.all(allStatPs); 43 | 44 | res.forEach(async (it, idx) => { 45 | const stats = allStats[idx]; 46 | if (isResultError(stats)) { 47 | console.log(`[${it.platform}] [${it.username}] [unoid ${it.accountId}]\n ERROR: ${stats.error}`); 48 | } else { 49 | console.log( 50 | `[${it.platform}] [${it.username}] [unoid ${it.accountId}]\n [${( 51 | Math.round(stats.results.kdRatio * 100) / 100 52 | ).toFixed(2)} kd] [${stats.results.gamesPlayed} games]` 53 | ); 54 | } 55 | }); 56 | } 57 | 58 | async function lookupUnoId(platform: string, username: string) { 59 | platform = platform.toLowerCase() == 'steam' ? 'battle' : platform; 60 | const stats = await API.MWcombatwz(username, platform) 61 | .then(res => { 62 | return { status: 'ok', results: res.matches[0].player.uno }; 63 | }) 64 | .catch(error => { 65 | return { status: 'error', error }; 66 | }); 67 | 68 | if (isResultError(stats)) { 69 | console.log(`ERROR: ${stats.error}`); 70 | } else { 71 | console.log(`[${platform}] [${username}] [unoid ${stats.results}]`); 72 | } 73 | } 74 | 75 | /* 76 | * CLI handler 77 | */ 78 | 79 | (async () => { 80 | if (!SSO) { 81 | console.error('Must set envvar [COD_SSO]'); 82 | process.exit(1); 83 | } 84 | 85 | const args = process.argv.slice(2); 86 | const mode = args[0]; 87 | 88 | try { 89 | await loginIfNeeded(); 90 | switch(mode) { 91 | case 'search': { 92 | const playerTag = args[1]; 93 | if (!playerTag) { 94 | console.error('ERROR: must provide a player tag'); 95 | process.exit(1); 96 | } 97 | await searchTag(playerTag); 98 | break; 99 | } 100 | case 'id': { 101 | const playerPlatform = args[1]; 102 | const playerTag = args[2]; 103 | if (! playerPlatform || !playerTag) { 104 | console.error('ERROR: must provide both a player name and tag'); 105 | process.exit(1); 106 | } 107 | await lookupUnoId(playerPlatform, playerTag); 108 | break; 109 | } 110 | default: { 111 | console.error(`Unrecognized mode [${mode}]. Please provide one of [search, id].`); 112 | process.exit(1); 113 | } 114 | } 115 | } catch (err) { 116 | console.log('--------------------------------------------------------------------------------'); 117 | console.error('ERROR:'); 118 | console.error(err); 119 | process.exit(1); 120 | } 121 | })(); 122 | -------------------------------------------------------------------------------- /frontend/resources/styles/base.css: -------------------------------------------------------------------------------- 1 | /* Small devices (landscape phones, 576px and up) */ 2 | /* @media (min-width: 576px) { ... } */ 3 | /* Medium devices (tablets, 768px and up) */ 4 | /* @media (min-width: 768px) { ... } */ 5 | /* Large devices (desktops, 992px and up) */ 6 | /* @media (min-width: 992px) { ... } */ 7 | /* Extra large devices (large desktops, 1200px and up) */ 8 | /* @media (min-width: 1200px) { ... } */ 9 | 10 | :root { 11 | /***********/ 12 | /* theming */ 13 | /***********/ 14 | 15 | --background-color: #121212; 16 | --on-background-color: #fff; 17 | 18 | --surface-color: 48, 48, 48; 19 | --on-surface-color: 255, 255, 255; 20 | 21 | --primary-color: 184, 240, 163; 22 | 23 | /**********************/ 24 | /* responsive styling */ 25 | /**********************/ 26 | 27 | --body-margin: 0.5rem; 28 | } 29 | 30 | @media(min-width: 992px) { 31 | :root { 32 | --body-margin: 2rem; 33 | } 34 | } 35 | 36 | body { 37 | margin: var(--body-margin); 38 | background: var(--background-color); 39 | color: var(--on-background-color); 40 | } 41 | 42 | a { 43 | color: rgba(var(--primary-color), 1.0); 44 | } 45 | a:hover { 46 | color: rgba(var(--primary-color), 0.5); 47 | } 48 | 49 | h1 { 50 | font-size: 8.0rem; 51 | } 52 | 53 | h2 { 54 | font-size: 5.0rem; 55 | } 56 | 57 | h3 { 58 | font-size: 3.0rem; 59 | } 60 | 61 | h4 { 62 | font-size: 2.0rem; 63 | } 64 | 65 | h5 { 66 | font-size: 1.5rem; 67 | } 68 | 69 | h6 { 70 | font-size: 1.25rem; 71 | } 72 | 73 | .text-capitalize { 74 | text-transform: capitalize; 75 | } 76 | 77 | .records-table-container>*, 78 | .teamrecords-table-container>*, 79 | .matches-table-container>*, 80 | .sessions-table-container>*, 81 | .games-table-container>* { 82 | margin-top: 2rem; 83 | } 84 | 85 | .last-updated-text { 86 | position: fixed; 87 | right: 1rem; 88 | bottom: 1rem; 89 | font-size: small; 90 | } 91 | 92 | .card { 93 | transition: 0.3s; 94 | padding: 1rem; 95 | display: inline-block; 96 | background: rgba(var(--surface-color), 0.8); 97 | color: rgba(var(--on-surface-color), 1.0); 98 | } 99 | 100 | .card .card-deemphasize { 101 | color: rgba(var(--on-surface-color), 0.6); 102 | } 103 | 104 | .card:hover { 105 | background: rgba(var(--surface-color), 0.95); 106 | } 107 | 108 | .records, 109 | .teamrecords, 110 | .matches, 111 | .sessions { 112 | display: grid; 113 | grid-gap: 1rem; 114 | } 115 | 116 | .records { 117 | grid-template-columns: repeat(auto-fill, minmax(12.5em, 1fr)); 118 | } 119 | 120 | .records .card { 121 | text-align: center; 122 | } 123 | 124 | .records .card--title, 125 | .teamrecords .card--title { 126 | font-size: 1.1rem; 127 | } 128 | 129 | .records .card--value { 130 | font-size: 2rem; 131 | margin: 1rem 0; 132 | } 133 | 134 | .records .card--attribution { 135 | font-size: 0.8rem; 136 | text-transform: capitalize; 137 | } 138 | 139 | .records .card--player-img { 140 | border-radius: 9999px; 141 | vertical-align: middle; 142 | width: 1rem; 143 | height: 1rem; 144 | margin-right: 0.25rem; 145 | border: 1px solid rgb(255 255 255 / 60%); 146 | } 147 | 148 | .records .card--date-text { 149 | vertical-align: middle; 150 | } 151 | 152 | .matches, 153 | .sessions { 154 | grid-template-columns: repeat(auto-fill, minmax(18em, 26.5em)); 155 | justify-content: center; 156 | } 157 | 158 | .matches .card.card-winner { 159 | --surface-color: 205, 153, 15; 160 | } 161 | 162 | .matches .card--date { 163 | float: left; 164 | } 165 | 166 | .matches .card--match-type { 167 | text-align: right; 168 | } 169 | 170 | .matches .card--player-names { 171 | text-align: center; 172 | margin-top: .5rem; 173 | text-transform: capitalize; 174 | } 175 | 176 | .matches .card--placement { 177 | clear: both; 178 | font-size: 1.875rem; 179 | text-align: center; 180 | margin-top: 2rem; 181 | } 182 | 183 | .matches .card--stats-container, 184 | .sessions .card--stats-container { 185 | display: flex; 186 | flex-flow: row wrap; 187 | justify-content: space-around; 188 | margin-top: 1.5rem; 189 | } 190 | 191 | .matches .card--stats-stat, 192 | .sessions .card--stats-stat { 193 | text-align: center; 194 | margin: 0 0.25rem 0.5rem 0.25rem; 195 | } 196 | 197 | .matches .card--stats-stat-value, 198 | .sessions .card--stats-stat-value { 199 | margin-bottom: 0.25rem; 200 | } 201 | 202 | .matches .card--stats-stat-name, 203 | .sessions .card--stats-stat-name {} 204 | 205 | .sessions .card--player-text { 206 | text-transform: capitalize; 207 | font-size: 1.5rem; 208 | } 209 | 210 | .teamrecords { 211 | grid-template-columns: repeat(auto-fill, minmax(35em, 1fr)); 212 | } 213 | 214 | .sortable-table { 215 | width: 100%; 216 | margin-top: 1rem; 217 | } 218 | 219 | .sortable-table th { 220 | text-align: left; 221 | white-space: nowrap; 222 | } 223 | 224 | .sortable-table th, 225 | .sortable-table td { 226 | padding: 0.25rem 0.5rem 0.25rem 0; 227 | } 228 | 229 | .sortable-table th { 230 | padding-bottom: 0.5rem; 231 | } 232 | .sortable-table th:last-child, 233 | .sortable-table td:last-child { 234 | padding-right: 0; 235 | } 236 | 237 | .sortable-table tr:nth-child(even) { 238 | background-color: rgba(255, 255, 255, 0.05); 239 | } 240 | -------------------------------------------------------------------------------- /frontend/resources/vendor/scripts/gray.js: -------------------------------------------------------------------------------- 1 | /* * 2 | * 3 | * (c) 2010-2020 Torstein Honsi 4 | * 5 | * License: www.highcharts.com/license 6 | * 7 | * Gray theme for Highcharts JS 8 | * 9 | * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! 10 | * 11 | * */ 12 | // 'use strict'; 13 | // import Highcharts from '../parts/Globals.js'; 14 | // import U from '../parts/Utilities.js'; 15 | // var setOptions = U.setOptions; 16 | Highcharts.theme = { 17 | colors: ['#DDDF0D', '#7798BF', '#55BF3B', '#DF5353', '#aaeeee', 18 | '#ff0066', '#eeaaee', '#55BF3B', '#DF5353', '#7798BF', '#aaeeee'], 19 | chart: { 20 | backgroundColor: 'rgb(16, 16, 16)', 21 | borderWidth: 0, 22 | borderRadius: 0, 23 | plotBackgroundColor: null, 24 | plotShadow: false, 25 | plotBorderWidth: 0 26 | }, 27 | title: { 28 | style: { 29 | color: '#FFF', 30 | font: '16px Lucida Grande, Lucida Sans Unicode,' + 31 | ' Verdana, Arial, Helvetica, sans-serif' 32 | } 33 | }, 34 | subtitle: { 35 | style: { 36 | color: '#DDD', 37 | font: '12px Lucida Grande, Lucida Sans Unicode,' + 38 | ' Verdana, Arial, Helvetica, sans-serif' 39 | } 40 | }, 41 | xAxis: { 42 | gridLineWidth: 0, 43 | lineColor: '#999', 44 | tickColor: '#999', 45 | labels: { 46 | style: { 47 | color: '#999', 48 | fontWeight: 'bold' 49 | } 50 | }, 51 | title: { 52 | style: { 53 | color: '#AAA', 54 | font: 'bold 12px Lucida Grande, Lucida Sans Unicode,' + 55 | ' Verdana, Arial, Helvetica, sans-serif' 56 | } 57 | } 58 | }, 59 | yAxis: { 60 | alternateGridColor: null, 61 | minorTickInterval: null, 62 | gridLineColor: 'rgba(255, 255, 255, .1)', 63 | minorGridLineColor: 'rgba(255,255,255,0.07)', 64 | lineWidth: 0, 65 | tickWidth: 0, 66 | labels: { 67 | style: { 68 | color: '#999', 69 | fontWeight: 'bold' 70 | } 71 | }, 72 | title: { 73 | style: { 74 | color: '#AAA', 75 | font: 'bold 12px Lucida Grande, Lucida Sans Unicode,' + 76 | ' Verdana, Arial, Helvetica, sans-serif' 77 | } 78 | } 79 | }, 80 | legend: { 81 | backgroundColor: 'rgba(48, 48, 48, 0.8)', 82 | itemStyle: { 83 | color: '#CCC' 84 | }, 85 | itemHoverStyle: { 86 | color: '#FFF' 87 | }, 88 | itemHiddenStyle: { 89 | color: '#333' 90 | }, 91 | title: { 92 | style: { 93 | color: '#E0E0E0' 94 | } 95 | } 96 | }, 97 | labels: { 98 | style: { 99 | color: '#CCC' 100 | } 101 | }, 102 | tooltip: { 103 | backgroundColor: { 104 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 105 | stops: [ 106 | [0, 'rgba(96, 96, 96, .8)'], 107 | [1, 'rgba(16, 16, 16, .8)'] 108 | ] 109 | }, 110 | borderWidth: 0, 111 | style: { 112 | color: '#FFF' 113 | } 114 | }, 115 | plotOptions: { 116 | series: { 117 | dataLabels: { 118 | color: '#444' 119 | }, 120 | nullColor: '#444444' 121 | }, 122 | line: { 123 | dataLabels: { 124 | color: '#CCC' 125 | }, 126 | marker: { 127 | lineColor: '#333' 128 | } 129 | }, 130 | spline: { 131 | marker: { 132 | lineColor: '#333' 133 | } 134 | }, 135 | scatter: { 136 | marker: { 137 | lineColor: '#333' 138 | } 139 | }, 140 | candlestick: { 141 | lineColor: 'white' 142 | } 143 | }, 144 | toolbar: { 145 | itemStyle: { 146 | color: '#CCC' 147 | } 148 | }, 149 | navigation: { 150 | buttonOptions: { 151 | symbolStroke: '#DDDDDD', 152 | theme: { 153 | fill: { 154 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 155 | stops: [ 156 | [0.4, '#606060'], 157 | [0.6, '#333333'] 158 | ] 159 | }, 160 | stroke: '#000000' 161 | } 162 | } 163 | }, 164 | // scroll charts 165 | rangeSelector: { 166 | buttonTheme: { 167 | fill: { 168 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 169 | stops: [ 170 | [0.4, '#888'], 171 | [0.6, '#555'] 172 | ] 173 | }, 174 | stroke: '#000000', 175 | style: { 176 | color: '#CCC', 177 | fontWeight: 'bold' 178 | }, 179 | states: { 180 | hover: { 181 | fill: { 182 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 183 | stops: [ 184 | [0.4, '#BBB'], 185 | [0.6, '#888'] 186 | ] 187 | }, 188 | stroke: '#000000', 189 | style: { 190 | color: 'white' 191 | } 192 | }, 193 | select: { 194 | fill: { 195 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 196 | stops: [ 197 | [0.1, '#000'], 198 | [0.3, '#333'] 199 | ] 200 | }, 201 | stroke: '#000000', 202 | style: { 203 | color: 'yellow' 204 | } 205 | } 206 | } 207 | }, 208 | inputStyle: { 209 | backgroundColor: '#333', 210 | color: 'silver' 211 | }, 212 | labelStyle: { 213 | color: 'silver' 214 | } 215 | }, 216 | navigator: { 217 | handles: { 218 | backgroundColor: '#666', 219 | borderColor: '#AAA' 220 | }, 221 | outlineColor: '#CCC', 222 | maskFill: 'rgba(16, 16, 16, 0.5)', 223 | series: { 224 | color: '#7798BF', 225 | lineColor: '#A6C7ED' 226 | } 227 | }, 228 | scrollbar: { 229 | barBackgroundColor: { 230 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 231 | stops: [ 232 | [0.4, '#888'], 233 | [0.6, '#555'] 234 | ] 235 | }, 236 | barBorderColor: '#CCC', 237 | buttonArrowColor: '#CCC', 238 | buttonBackgroundColor: { 239 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 240 | stops: [ 241 | [0.4, '#888'], 242 | [0.6, '#555'] 243 | ] 244 | }, 245 | buttonBorderColor: '#CCC', 246 | rifleColor: '#FFF', 247 | trackBackgroundColor: { 248 | linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, 249 | stops: [ 250 | [0, '#000'], 251 | [1, '#333'] 252 | ] 253 | }, 254 | trackBorderColor: '#666' 255 | } 256 | }; 257 | // Apply the theme 258 | Highcharts.setOptions(Highcharts.theme); 259 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Adapted from https://unix.stackexchange.com/a/348432 and https://lithic.tech/blog/2020-05/makefile-dot-env/ 2 | ifneq (,$(wildcard ./config/env)) 3 | include ./config/env 4 | export 5 | else 6 | $(error env file doenst exist in the config folder, please see config/env.example for the format.) 7 | endif 8 | 9 | AWS_CMD = docker run --rm --env AWS_ACCESS_KEY_ID='$(AWS_ACCESS_KEY_ID)' --env AWS_SECRET_ACCESS_KEY='$(AWS_SECRET_ACCESS_KEY)' amazon/aws-cli 10 | AWS_ECS_CMD = docker run --rm --env AWS_ACCESS_KEY_ID='$(AWS_ACCESS_KEY_ID)' --env AWS_SECRET_ACCESS_KEY='$(AWS_SECRET_ACCESS_KEY)' --env AWS_DEFAULT_REGION=$(AWS_REGION) amazon/aws-cli -- ecs 11 | AWS_S3_PUBLIC_URL = https://$(AWS_S3_BUCKET_NAME).$(HOST_REGION).$(HOST_PROVIDER)/index.html 12 | AWS_S3_ENDPOINT = https://$(HOST_REGION).$(HOST_PROVIDER) 13 | 14 | ifeq ($(OS),Windows_NT) 15 | # NOTE(jpr): see https://www.reddit.com/r/docker/comments/734arg/cant_figure_out_how_to_bash_into_docker_container/dnnz2uq/ 16 | BIN_SH_PATH = //bin/sh 17 | else 18 | BIN_SH_PATH = /bin/sh 19 | endif 20 | 21 | # Just using Make as a generic task-runner, not a compilation pipeline 22 | .PHONY: % 23 | 24 | ################################################################################ 25 | # Main targets 26 | ################################################################################ 27 | 28 | docker-run: docker-build 29 | docker run --rm -v $(shell pwd)/.data:/opt/data \ 30 | --env AWS_ACCESS_KEY_ID='$(AWS_ACCESS_KEY_ID)' \ 31 | --env AWS_SECRET_ACCESS_KEY='$(AWS_SECRET_ACCESS_KEY)' \ 32 | --env S3_BUCKET_NAME='$(AWS_S3_BUCKET_NAME)' \ 33 | --env S3_ENDPOINT='$(AWS_S3_ENDPOINT)' \ 34 | --env COD_SSO='$(COD_API_SSO)' \ 35 | $(DOCKER_IMG_TAG) 36 | @echo 37 | @echo Deployment complete. You should be able to view your site at $(AWS_S3_PUBLIC_URL) 38 | 39 | docker-query-player: ensure-args docker-build-quiet 40 | docker run --rm \ 41 | --env COD_SSO='$(COD_API_SSO)' \ 42 | $(DOCKER_IMG_TAG) $(BIN_SH_PATH) -c "cd fetcher && npm run-script query-player $(ARGS)" 43 | 44 | check-bootstrap: silent-by-default check-docker-is-installed check-players-json-created ensure-api-credentials-set check-api-credentials-work ensure-aws-credentials-set check-aws-credentials-work ensure-s3-bucket-name-set check-s3-bucket-exists check-s3-bucket-is-website check-s3-bucket-has-public-policy 45 | @echo Everything looks good for bucket [$(AWS_S3_BUCKET_NAME)] 46 | @echo You should be able to view your site at $(AWS_S3_PUBLIC_URL) after you run \`make docker-run\` 47 | 48 | ensure-bootstrap: silent-by-default check-docker-is-installed check-players-json-created ensure-api-credentials-set check-api-credentials-work ensure-aws-credentials-set check-aws-credentials-work ensure-s3-bucket-name-set ensure-s3-bucket-exists ensure-s3-bucket-is-website ensure-s3-bucket-has-public-policy 49 | @echo Everything should be setup for bucket [$(AWS_S3_BUCKET_NAME)] 50 | @echo You should be able to view your site at $(AWS_S3_PUBLIC_URL) after you run \`make docker-run\` 51 | 52 | # https://developers.digitalocean.com/documentation/spaces/#aws-s3-compatibility 53 | do-ensure-bootstrap: silent-by-default check-docker-is-installed check-players-json-created ensure-api-credentials-set check-api-credentials-work ensure-aws-credentials-set ensure-s3-bucket-name-set ensure-s3-bucket-exists ensure-s3-bucket-has-public-policy 54 | @echo Everything should be setup for bucket [$(AWS_S3_BUCKET_NAME)] 55 | @echo You should be able to view your site at $(AWS_S3_PUBLIC_URL) after you run \`make docker-run\` 56 | 57 | ################################################################################ 58 | # Other targets 59 | ################################################################################ 60 | 61 | docker-push: docker-build docker-login 62 | docker tag $(DOCKER_IMG_TAG):latest $(AWS_ECR_URL)/$(DOCKER_IMG_TAG):latest 63 | docker push $(AWS_ECR_URL)/$(DOCKER_IMG_TAG):latest 64 | 65 | aws-delete-bucket: 66 | @echo "This will delete everything in [$(AWS_S3_BUCKET_NAME)]. Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] 67 | $(AWS_CMD) s3 rb s3://$(AWS_S3_BUCKET_NAME) --force 68 | 69 | aws-list-ecs-tasks: 70 | @echo running: 71 | $(AWS_ECS_CMD) list-tasks --desired-status running --cluster default 72 | @echo stopped: 73 | $(AWS_ECS_CMD) list-tasks --desired-status stopped --cluster default 74 | 75 | ################################################################################ 76 | # Helpers 77 | ################################################################################ 78 | 79 | docker-build: 80 | docker build -t $(DOCKER_IMG_TAG) . 81 | 82 | docker-build-quiet: 83 | docker build -q -t $(DOCKER_IMG_TAG) . >/dev/null 84 | 85 | docker-login: 86 | $(AWS_CMD) ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $(AWS_ECR_URL) 87 | 88 | ensure-api-credentials-set: 89 | ifndef COD_API_SSO 90 | $(error COD_API_SSO is undefined) 91 | endif 92 | ifeq ($(COD_API_SSO),'') 93 | $(error COD_API_SSO is not set) 94 | endif 95 | 96 | ensure-aws-credentials-set: 97 | ifndef AWS_ACCESS_KEY_ID 98 | $(error AWS_ACCESS_KEY_ID is undefined) 99 | endif 100 | ifeq ($(AWS_ACCESS_KEY_ID),'') 101 | $(error AWS_ACCESS_KEY_ID is not set) 102 | endif 103 | ifndef AWS_SECRET_ACCESS_KEY 104 | $(error AWS_SECRET_ACCESS_KEY is undefined) 105 | endif 106 | ifeq ($(AWS_SECRET_ACCESS_KEY),'') 107 | $(error AWS_SECRET_ACCESS_KEY is not set) 108 | endif 109 | 110 | check-docker-is-installed: 111 | which docker >/dev/null || (echo "docker is not installed, please install it first!" && exit 1) 112 | 113 | check-players-json-created: 114 | [ -f config/players.json ] || (echo 'No players.json created in the config folder. Please see config/players.json.example for the format.' && exit 1) 115 | 116 | check-aws-credentials-work: 117 | $(AWS_CMD) sts get-caller-identity >/dev/null || (echo "AWS credentials didnt work, please check them at https://console.aws.amazon.com/iam/home#/security_credentials" && exit 1) 118 | 119 | check-api-credentials-work: docker-build-quiet 120 | docker run --rm \ 121 | --env COD_SSO='$(COD_API_SSO)' \ 122 | $(DOCKER_IMG_TAG) $(BIN_SH_PATH) -c "cd fetcher && npm run-script check-credentials" >/dev/null 2>&1 || (echo "COD credentials didnt work, please check them at https://my.callofduty.com/login" && exit 1) 123 | 124 | check-s3-bucket-exists: 125 | $(AWS_CMD) s3api head-bucket --bucket $(AWS_S3_BUCKET_NAME) --endpoint-url $(AWS_S3_ENDPOINT) >/dev/null || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] doesnt exist, create it first!" && exit 1) 126 | 127 | check-s3-bucket-is-website: 128 | $(AWS_CMD) s3api get-bucket-website --bucket $(AWS_S3_BUCKET_NAME) --endpoint-url $(AWS_S3_ENDPOINT) >/dev/null || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] is not an s3 website, enable the configuration!" && exit 1) 129 | 130 | check-s3-bucket-has-public-policy: 131 | $(AWS_CMD) s3api get-bucket-policy --bucket $(AWS_S3_BUCKET_NAME) --endpoint-url $(AWS_S3_ENDPOINT) >/dev/null || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] doesnt have a public policy, attach it first!" && exit 1) 132 | 133 | ensure-s3-bucket-exists: 134 | # NOTE(jpr): aws has different rules for us-east-1.... 135 | ifeq ($(AWS_REGION),us-east-1) 136 | $(MAKE) check-s3-bucket-exists >/dev/null 2>&1 || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] doesnt exist, creating.." && $(AWS_CMD) s3api create-bucket --bucket $(AWS_S3_BUCKET_NAME) --region $(AWS_REGION) >/dev/null) 137 | else 138 | $(MAKE) check-s3-bucket-exists >/dev/null 2>&1 || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] doesnt exist, creating.." && $(AWS_CMD) s3api create-bucket --bucket $(AWS_S3_BUCKET_NAME) --region $(AWS_REGION) --create-bucket-configuration LocationConstraint=$(AWS_REGION) >/dev/null) 139 | endif 140 | 141 | ensure-s3-bucket-is-website: 142 | $(MAKE) check-s3-bucket-is-website >/dev/null 2>&1 || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] is not an s3 website, enabling the configuration.." && $(AWS_CMD) s3 website s3://$(AWS_S3_BUCKET_NAME) --index-document index.html) 143 | 144 | ensure-s3-bucket-has-public-policy: 145 | $(MAKE) check-s3-bucket-has-public-policy >/dev/null 2>&1 || (echo "Bucket [$(AWS_S3_BUCKET_NAME)] doesnt have a public policy, attaching.." && $(AWS_CMD) s3api --endpoint-url=$(AWS_S3_ENDPOINT) put-bucket-policy --bucket $(AWS_S3_BUCKET_NAME) --policy '{ "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::$(AWS_S3_BUCKET_NAME)/*" } ] }') 146 | 147 | ensure-s3-bucket-name-set: 148 | ifndef AWS_S3_BUCKET_NAME 149 | $(error AWS_S3_BUCKET_NAME is undefined) 150 | endif 151 | ifeq ($(AWS_S3_BUCKET_NAME),'') 152 | $(error AWS_S3_BUCKET_NAME is not set) 153 | endif 154 | 155 | ensure-args: 156 | ifndef ARGS 157 | $(error ARGS is undefined) 158 | endif 159 | 160 | silent-by-default: 161 | ifndef VERBOSE 162 | .SILENT: 163 | endif 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Call of Duty stats tracker 2 | 3 | A simple to setup / run micro site for tracking individual and team stats for COD: Modern Warfare (specifically Warzone at the moment). 4 | 5 | This project is intended to be a way to easily: 6 | 7 | 1. Figure out the true performance of members of a playgroup, rather than just relying on, e.g., lifetime K/D ratio 8 | 1. Figure out if you are improving over time in certain categories, e.g., gulag win % or Damage / Kill 9 | 10 | It leverages an existing (mostly undocumented?) activision API that powers my.callofduty.com to pull the data. It then parses that data into a sqlite database and generates statistics to then be uploaded to an S3 static website for viewing/sharing with others. 11 | 12 | Note that this project is very much intended to be used for a group of players that regularly play together. Some of the sql reporting is specific to 'full squad games' (i.e. you played trios and all 3 players were from your play group). That said, it can be run just fine to track individual progress over time. 13 | 14 | 15 | ## Screenshots 16 | 17 | You can see an live example running at https://codstats-frontend.s3.amazonaws.com/index.html 18 | 19 | Main page / Player page 20 | 21 | 22 | 23 | 24 | ## Requirements: 25 | 26 | 1. Docker 27 | 1. GNU Make 28 | 29 | Neither of these are _actually_ required, but it is very much the preferred setup. If you run the equivalent make targets directly, there are more dependencies you need to ensure are setup correctly (e.g. aws cli, jq, sqlite). Have a look at the `Dockerfile` and `Makefile` to get an idea of how you might accomplish this. 30 | 31 | On windows I've only verified that everything works if run via `git-bash` (typically located at `C:\Program Files\Git\bin\bash.exe`). 32 | 33 | 34 | ## Getting started 35 | 36 | Before running you will want to create a `config/players.json` and `config/env` file from the example files provided (`config/players.json.example` and `config/env.example`). See the 'Setting up players.json' section below for help. 37 | 38 | The first time you setup the project you want to run `make ensure-bootstrap` to be sure everything is setup and configured correctly. After that you can run `make docker-run` whenever you want to fetch and publish new data to your s3 website. 39 | 40 | In other words, do this the first time: 41 | 42 | ```sh 43 | cp config/env.example config/env 44 | # ... fill in env ... 45 | 46 | cp config/players.json.example config/players.json 47 | # ... fill in players.json ... 48 | 49 | make ensure-bootstrap && make docker-run 50 | # If you are hosting on DigitalOcean Spaces... 51 | make do-ensure-bootstrap && make docker-run 52 | ``` 53 | 54 | and then to update with newer data in the future, just run this: 55 | ``` 56 | make docker-run 57 | ``` 58 | 59 | 60 | ## Setting up players.json 61 | 62 | In order to have a correct `players.json` there are a couple things to keep in mind. 63 | 64 | First, you _need_ at least 1 'core' player, but may also have extra 'non-core' players. A core player is someone who should be tracked and taken into account when figuring out "best of" stats, etc. A non-core player will still be shown in the charts (though they won't be enabled by default), but will not be taken into account for team/player leaderboards. I use non-core players to track a couple pros so that we have a baseline of what "good" numbers might look like for a given chart. 65 | 66 | If you do add pro players to be tracked, keep in mind that pro players generally play a lot more games than non-pros, and so the number of files and size of the DB can grow much larger which tracking them. Also, the initial sync can run into rate limiting issues because of this. 67 | 68 | Second, you need all 3 of the player's platform, tag, and uno id for every player. To find the uno id for a player, first run `make docker-query-player ARGS='search {player-name}'` and find the most likely account based on K/D ratio, number of games, and platform. Then, use the platform and tag to call `make docker-query-player ARGS='id {platform} {tag}'`. This should list their uno id. Its too expensive in API calls to automatically lookup uno ids for each possible player in the search, so these are 2 separate steps for now. 69 | 70 | ```sh 71 | $ make docker-query-player ARGS='search JamesSwift' 72 | > found [1] results... 73 | 74 | [battle] [JamesSwift#1805] [unoid undefined] 75 | [0.87 kd] [501 games] 76 | 77 | # Since the unoid is undefined above, we need to query it separately... 78 | $ make docker-query-player ARGS='id battle JamesSwift#1805' 79 | [battle] [JamesSwift#1805] [unoid 2391270] 80 | ``` 81 | 82 | Note that a single player entry in `players.json` can be associated with multiple platform/tag/uno-id configurations, in case you want to merge account stats together into one entry. This happens sometimes if people change their player tag, or start a new account. 83 | 84 | 85 | ## System architecture 86 | 87 | There are 3 main projects/phases to the system: the Fetcher, Parser, and Frontend. They are setup as distinct steps to help with decoupling, maintainability, and idempotency. The system is pretty resilient, you can fix most errors by just deleting the DB and/or match files and pulling the data from the API again. 88 | 89 | 90 | ### Fetcher 91 | 92 | This is a typescript project which takes care of calling the activision API and downloading all the stats for any games it doesnt know about for all the players. 93 | 94 | The fetcher project also has additional helper scripts for checking api credentials and querying player ids. These all live here because they all rely on the same [Call of Duty NPM package](https://github.com/Lierrmm/Node-CallOfDuty). 95 | 96 | 97 | ### Parser 98 | 99 | This is a bash script which takes the previously downloaded matches which are stored on the filesystem as flat json files, and creates / updates a sqlite database with the data. The database only has a couple _real_ tables, the rest are virtual (aka views). This is so that migrations are infrequently needed, and storage size is minimized. This has performance implications, but this hasn't been a concern yet when running locally. It _can_ come into play when running on, e.g., EFS on AWS. 100 | 101 | 102 | ### Frontend 103 | 104 | This is a bash script which takes the sqlite file and generates static JSON-ish reports on various aspects of the players/seasons. It then has some html/css/JS that consumes these reports statically. Note this is vanilla CSS/JS, no frameworks at the moment to keep complexity down. I may move to a component-based JS framework later, its just getting to be annoying enough without one. 105 | 106 | The frontend project also has a deploy script which pushes the generated files to S3. 107 | 108 | 109 | ## FAQ 110 | 111 | **Q**: What do I do if I get a Rate Limit error from the activision API? 112 | **A**: This is fine, just dont run the project again for a few hours and it should automatically reset. The code can keep most of its interim progress so that you won't constantly be rate limited after an initial sync. 113 | 114 | **Q**: I messed up my database / match files, how do I fix it? 115 | **A**: You can safely delete the database and it will be recreated on demand. Same with the match files, but I would recommend deleting the database as well in that case to be sure bad data didn't get inserted. 116 | 117 | **Q**: How do I automatically update the stats? 118 | **A**: The easiest way would be to run a local cronjob (e.g. every 20-30 minutes). I personally have the docker container running in AWS ECS as a scheduled task so that I dont need my computer to be on, using EFS as durable storage so each job takes the minimum amount of time to complete. This is much more difficult to setup however, so its not officially supported. If you _do_ set this up for yourself, you can use the `make docker-push` command to build and update your ECR image. 119 | 120 | **Q**: I'm running this on ECS with EFS like you said, but its going really slow! What gives? 121 | **A**: The burstable IOPS mode of EFS is not a good fit for our use case as we read/write a lot of small data as well as open/close the sqlite file repeatedly. Its very easy to deplete your burst credits and so you will want to enabled Provisioned IOPS for EFS to get around this, or set a much longer cron window (probably 1 hour at the minimum). 122 | 123 | **Q**: Warzone is great, but what about multiplayer stats? 124 | **A**: The support for pulling / ingesting the multiplayer stats is all in place, I just haven't gotten around to designing the UI/UX/metrics of it. There are a lot of game modes to think about and its tough to have useful metrics and keep the mobile UI workable. 125 | 126 | **Q**: Why do I need to put a players platform, tag, and uno id in the `players.json` file? 127 | **A**: Ideally, this would only require uno id, since thats what the DB uses to track and distinguish players. The problem is that the activision API is not consistent in how it treats uno id when you use certain endpoints though. For example, if you ask for all the matches for a given player using their uno id, it might return nothing. If you ask using platform/tag then it returns all their games. 128 | 129 | **Q**: Why sqlite? 130 | **A**: Sqlite does a ton out of the box and is able to be stored as a single file, which makes deployment and project setup much easier. It also keeps deployment costs minimal, since you only need durable disk storage, and dont need to pay for a managed DB instance. I think in the future this could eventually move to Postgres. 131 | 132 | **Q**: Why static files for the frontend? 133 | **A**: Again, this is for deployment and configuration ease of use. Its much easier to setup an S3 static site for someone than it is for me to assist in hosting as a real API service with real DB calls. This has worked nicely so far, but might move to a proper backend at some point as filter/sort options become more prevalent. 134 | 135 | **Q**: What are these random `default.nix` files? 136 | **A**: This is for [Nix package manager](https://nixos.org/download.html), you can ignore those. I use `Nix` for my local sandboxing instead of `docker`. 137 | 138 | **Q**: How do I set player photos? 139 | **A**: This is a bit of a hack at the moment. You need to put a .jpg (_MUST BE .JPG_!) file into `frontend/resources/images/players` named with the player namer from `players.json`. So, if you have a player in `players.json` with `name: 'Jimmy'` then you need a file at `frontend/resources/images/players/jimmy.jpg`. 140 | 141 | **Q**: What is a session? 142 | **A**: A session is currently defined as one or more games that occur at least 2 hours after any other games. So if you played 3 games, then waited 2 hours and played another game, that would be 2 sessions. However, if you played the 4th game just 1.5 hours later, it would be considered to be a part of the same session. The 2 hours is arbitrary (set in the `parse_matches.sh` `create_tables` function), and might be adjusted in the future. 143 | 144 | **Q**: How do I test the site locally? 145 | **A**: Since the default setup encapsulates in the Docker container, you need to run the `run_and_deploy.sh` script locally and then serve those files to your browser. I use pythons built in http server (`cd .data/frontend/output && python -m SimpleHTTPServer`). 146 | 147 | **Q**: What can I use to browse the data in the DB? 148 | **A**: I use [DB Browser for SQLite](https://github.com/sqlitebrowser/sqlitebrowser) on my Mac 149 | 150 | 151 | ## TODOs 152 | 153 | - Add multiplayer reporting + UI. The stats fetching is implemented and storage is mostly implemented. 154 | - Complete game mode mappings 155 | - More robust filtering/selection. e.g. allow breakdown of stats by solo/duos/trios/quads 156 | - Add weapon/loadout statistics 157 | - Add CDK based deployment scripts 158 | -------------------------------------------------------------------------------- /frontend/resources/scripts/player.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const deepFetch = (obj, keyPath) => { 3 | for (let idx = 0; idx < keyPath.length; idx++) { 4 | const key = keyPath[idx]; 5 | obj = obj[key]; 6 | if (!obj) { 7 | break; 8 | } 9 | } 10 | return obj; 11 | }; 12 | 13 | const roundTo2 = (number) => { 14 | return Math.round(100 * number) / 100; 15 | }; 16 | 17 | let _queryParams = null; 18 | let _playerStatsInfo = null; 19 | let _gamesInfo = []; 20 | let _sessionsInfo = []; 21 | 22 | const loadPlayerStatsData = async (playerName) => { 23 | let playerFound = true; 24 | 25 | const data = await fetch(`/data/output/${playerName}_player_stats.json`) 26 | .then(response => { 27 | if (response.status == 404) { 28 | throw Error(response.statusText); 29 | } 30 | return response.text(); 31 | }) 32 | .then(it => JSON.parse(it)) 33 | .catch(e => { 34 | playerFound = false; 35 | console.error(e) 36 | }); 37 | 38 | if (!playerFound) { 39 | return; 40 | } 41 | _playerStatsInfo = data; 42 | }; 43 | 44 | const loadGamesData = async (playerName, titlizedName) => { 45 | let playerFound = true; 46 | const data = await fetch(`/data/output/${playerName}_lifetime_game_wz.json`) 47 | .then(response => { 48 | if (response.status == 404) { 49 | throw Error(response.statusText); 50 | } 51 | return response.text(); 52 | }) 53 | .then(it => { 54 | const entries = JSON.parse(it); 55 | 56 | return entries.filter(it => it.stats != null).map(it => { 57 | const dt = new Date(it.date); 58 | const stats = it.stats; 59 | return { ...stats.raw, start: dt }; 60 | }); 61 | }) 62 | .catch(e => { 63 | playerFound = false; 64 | console.error(e) 65 | }); 66 | 67 | if (!playerFound) { 68 | document.querySelector('.games-text').innerHTML = `No games found for [${titlizedName}]`; 69 | return; 70 | } 71 | _gamesInfo = data.sort((a, b) => b.start - a.start).slice(0, 25); 72 | 73 | document.querySelector('.games-text').innerHTML = `Last ${_gamesInfo.length} games:`; 74 | }; 75 | 76 | const loadSessionsData = async (playerName, titlizedName) => { 77 | let playerFound = true; 78 | const data = await fetch(`/data/output/sessions_${playerName}.json`) 79 | .then(response => { 80 | if (response.status == 404) { 81 | throw Error(response.statusText); 82 | } 83 | return response.text(); 84 | }) 85 | .then(it => JSON.parse(it)) 86 | .catch(e => { 87 | playerFound = false; 88 | console.error(e) 89 | }); 90 | 91 | if (!playerFound) { 92 | document.querySelector('.sessions-text').innerHTML = `No sessions found for [${titlizedName}]`; 93 | return; 94 | } 95 | _sessionsInfo = data.sessions.sort((a, b) => a.start - b.start); 96 | 97 | document.querySelector('.sessions-text').innerHTML = `${titlizedName} has played ${_sessionsInfo.length} sessions:`; 98 | }; 99 | 100 | const loadUpdatedAt = async (playerName) => { 101 | let playerFound = true; 102 | const data = await fetch(`/data/output/sessions_${playerName}_updated_at.json`) 103 | .then(response => { 104 | if (response.status == 404) { 105 | throw Error(response.statusText); 106 | } 107 | return response.text(); 108 | }) 109 | .then(it => JSON.parse(it)) 110 | .catch(e => { 111 | playerFound = false; 112 | console.error(e) 113 | }); 114 | 115 | if (!playerFound) { 116 | return; 117 | } 118 | 119 | const updatedAt = new Date(data.updatedAt); 120 | const formatted = updatedAt.toLocaleString('en-US', { hour: "numeric", minute: "numeric" }); 121 | document.querySelector('.last-updated-text').innerHTML = `Last Updated: ${formatted}`; 122 | }; 123 | 124 | const loadInitialData = async () => { 125 | const playerName = _queryParams.get('player').toLowerCase() 126 | const titlizedName = playerName[0].toUpperCase() + playerName.substr(1); 127 | document.querySelector('.profile-container--name').innerHTML = titlizedName; 128 | document.querySelector('.profile-container--image').src = `/resources/images/players/${playerName}.jpg`; 129 | await Promise.all([loadPlayerStatsData(playerName), loadGamesData(playerName, titlizedName), loadSessionsData(playerName, titlizedName), loadUpdatedAt(playerName)]); 130 | }; 131 | 132 | const hideEmptyModeMessage = () => { 133 | document.querySelector('#empty-mode-message').style.display = 'none'; 134 | }; 135 | const setEmptyMessage = (msg) => { 136 | const el = document.querySelector('#empty-mode-message'); 137 | el.innerHTML = msg; 138 | el.style.display = 'absolute'; 139 | }; 140 | 141 | const populatePlayerStats = async () => { 142 | if (_playerStatsInfo == null) { 143 | return; 144 | } 145 | 146 | const container = document.querySelector('.player-stats'); 147 | const data = _playerStatsInfo; 148 | 149 | const cardWithConfig = (name, numGames, metrics, placements) => { 150 | let html = ` 151 |
152 |
${name}
153 |

${numGames} ${numGames == 1 ? 'Game' : 'Games'}

154 |
155 |
156 | `; 157 | metrics.forEach(metric => { 158 | html += ` 159 |
160 |

${metric.name}

161 |

${metric.value}

162 |
163 | ` 164 | }); 165 | html += ` 166 |
167 |
168 | `; 169 | placements.forEach(placement => { 170 | html += ` 171 |
172 |

${placement.name}

173 |

${placement.value}

174 |
175 | ` 176 | }); 177 | html += ` 178 |
179 | `; 180 | 181 | const card = document.createElement('div'); 182 | card.className = 'card player-stats--card'; 183 | card.innerHTML = html; 184 | return card; 185 | }; 186 | 187 | data.forEach(timePeriod => { 188 | const card = cardWithConfig(timePeriod.displayName, timePeriod.numGames, timePeriod.metrics, timePeriod.placements); 189 | container.appendChild(card); 190 | }); 191 | }; 192 | 193 | const populateRecentGames = async () => { 194 | if (_gamesInfo.length == 0) { 195 | return; 196 | } 197 | const container = document.querySelector('.games-table-container'); 198 | 199 | const table = document.createElement('table'); 200 | table.className = 'recent-matches-table sortable-table sort-inverted'; 201 | let html = ` 202 | 203 | 204 | Time 205 | Mode 206 | Place 207 | Dmg 208 | K 209 | D 210 | K/D 211 | Gulag 212 | 213 | 214 | 215 | `; 216 | 217 | _gamesInfo.forEach(it => { 218 | const dateText = it.start.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }); 219 | const kdText = roundTo2(it.deaths == 0 ? it.kills : it.kills / it.deaths); 220 | let gulagText = " "; 221 | let gulagClassName = ""; 222 | if (it.gulagKills > 0) { 223 | gulagText = "✔︎"; // ✔; 224 | gulagClassName = "gulag-win"; 225 | } else if (it.gulagDeaths > 0) { 226 | gulagText = "✘︎"; // ✘; 227 | gulagClassName = "gulag-loss"; 228 | } 229 | html += ` 230 | 231 | ${dateText} 232 | ${it.mode} 233 | ${it.teamPlacement} / ${it.numberOfTeams} 234 | ${it.damageDone} 235 | ${it.kills} 236 | ${it.deaths} 237 | ${kdText} 238 | ${gulagText} 239 | 240 | `; 241 | }); 242 | html += ` 243 | 244 | `; 245 | 246 | table.innerHTML = html; 247 | 248 | const gamesElement = container.querySelector('.games'); 249 | gamesElement.parentNode.replaceChild(table, gamesElement); 250 | 251 | new Tablesort(table, { 252 | descending: true 253 | }); 254 | } 255 | 256 | const populateRecentSessions = async () => { 257 | const container = document.querySelector('.sessions'); 258 | const data = _sessionsInfo; 259 | 260 | const cardWithConfig = (playerText, numGames, numWins, top5s, top10s, gulagWins, gulagLosses, numKills, numDeaths, damageDone, maxKills, maxDamage) => { 261 | const kdText = roundTo2(numDeaths == 0 ? numKills : numKills / numDeaths); 262 | const html = ` 263 |
264 |

${playerText}

265 |

${numGames} ${numGames == 1 ? 'Game' : 'Games'}

266 |
267 |
268 |
269 |

${numWins}

270 |

${numWins == 1 ? 'Win' : 'Wins'}

271 |
272 |
273 |

${top5s}

274 |

${top5s == 1 ? 'Top 5' : 'Top 5s'}

275 |
276 |
277 |

${top10s}

278 |

${top10s == 1 ? 'Top 10' : 'Top 10s'}

279 |
280 |
281 |
282 |
283 |

${roundTo2(numKills / numGames)} / ${maxKills}

284 |

Kills (avg/best)

285 |
286 |
287 |

${Math.trunc(damageDone / numGames)} / ${maxDamage}

288 |

Damage (avg/best)

289 |
290 |
291 |
292 |
293 |

${kdText}

294 |

K/D

295 |
296 |
297 |

${roundTo2(100 * (gulagLosses == 0 ? 1 : gulagWins / (gulagLosses + gulagWins)))}%

298 |

Gulag Win %

299 |
300 |
301 | `; 302 | 303 | const card = document.createElement('div'); 304 | card.className = 'card' 305 | card.innerHTML = html; 306 | 307 | return card; 308 | }; 309 | 310 | data.forEach(rowData => { 311 | const s = rowData.stats; 312 | const dt = new Date(rowData.start).toLocaleDateString('en-US', { month: "short", day: "numeric" }); 313 | const card = cardWithConfig(dt, s.numGames, s.wins, s.top5, s.top10, s.gulagKills, s.gulagDeaths, s.kills, s.deaths, s.damageDone, s.maxKills, s.maxDamage); 314 | container.appendChild(card); 315 | }); 316 | }; 317 | 318 | const initialize = async () => { 319 | _queryParams = new URLSearchParams(window.location.search); 320 | const backButton = document.querySelector('.back-button'); 321 | if (document.referrer != "" && (new URL(document.referrer)).origin == window.location.origin) { 322 | backButton.href = document.referrer; 323 | } else { 324 | backButton.href = '/index.html'; 325 | } 326 | 327 | await loadInitialData(); 328 | 329 | populatePlayerStats(); 330 | populateRecentGames(); 331 | populateRecentSessions(); 332 | }; 333 | 334 | window.initialize = initialize; 335 | })(); 336 | -------------------------------------------------------------------------------- /fetcher/fetch_matches.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | const API = require('call-of-duty-api')(); 3 | 4 | const SSO = process.env.COD_SSO; 5 | const COD_DATADIR = process.env.COD_DATADIR; 6 | const OUTDIR = `${COD_DATADIR}/fetcher/output`; 7 | const RATE_LIMIT_FILE = `${COD_DATADIR}/fetcher/rate_limit_until.json`; 8 | const FAILURES_FILE = `${COD_DATADIR}/fetcher/failure_stats.json`; 9 | 10 | const codApiBatchLimit = 20; 11 | const codApiMatchResultLimit = 1000; 12 | const requestBatchLimit = 10; 13 | const initialRateLimitBackoffMins = 60; 14 | const maxFailuresBeforeCutoff = 50; 15 | 16 | // Typings 17 | 18 | type PlayerMapping = { playerName: string; activisionPlatform: string; activisionTag: string; unoId: string }; 19 | type ResultError = { status: 'error'; error: string }; 20 | type ResultOK = { status: 'ok'; results: T }; 21 | type Result = ResultOK | ResultError; 22 | type MatchResults = { multiplayer: any; warzone: any }; 23 | type StoredMatchData = { matchId: string; playerUnoId: string }; 24 | type RateLimitInfo = { lastBackoffMins: number; delayUntilUnix: number }; 25 | 26 | // Config / data 27 | 28 | const playerMappings: Record = JSON.parse( 29 | fs.readFileSync('../config/players.json', 'utf8') 30 | ).reduce((memo, it) => { 31 | const accounts = it.accounts.map(account => { 32 | return { ...account, playerName: it.name }; 33 | }); 34 | memo[it.name.toLowerCase()] = accounts; 35 | return memo; 36 | }, {}); 37 | 38 | // Rate limiting 39 | 40 | function currentUnixTimeSeconds() { 41 | return Math.trunc(Date.now() / 1000); 42 | } 43 | 44 | function getRateLimitInfo(): RateLimitInfo | null { 45 | if (!fs.existsSync(RATE_LIMIT_FILE)) { 46 | return null; 47 | } 48 | return JSON.parse(fs.readFileSync(RATE_LIMIT_FILE, 'utf8')) as RateLimitInfo; 49 | } 50 | 51 | function rateLimitBackoffRemaining() { 52 | const rateLimitInfo = getRateLimitInfo(); 53 | if (rateLimitInfo == null) { 54 | return 0; 55 | } 56 | const currentUnix = currentUnixTimeSeconds(); 57 | return rateLimitInfo.delayUntilUnix - currentUnix; 58 | } 59 | 60 | function isRateLimitError(error: any) { 61 | if (typeof error == 'string') { 62 | // TODO(jpr): patch lib to provide better error object 63 | return error.indexOf('429') >= 0 || error.indexOf('403') >= 0; 64 | } 65 | return false; 66 | } 67 | 68 | function writeNewRateLimitInfo() { 69 | const rateLimitInfo = getRateLimitInfo() ?? { lastBackoffMins: initialRateLimitBackoffMins / 2, delayUntilUnix: 0 }; 70 | // exponential backoff 71 | const newBackoffMins = rateLimitInfo.lastBackoffMins * 2; 72 | const newRateLimitInfo = { lastBackoffMins: newBackoffMins, delayUntilUnix: currentUnixTimeSeconds() + 60 * newBackoffMins }; 73 | console.info(`Backing off for [${newBackoffMins}] mins`); 74 | fs.writeFileSync(RATE_LIMIT_FILE, JSON.stringify(newRateLimitInfo)); 75 | } 76 | 77 | function deleteRateLimitInfo() { 78 | if (!fs.existsSync(RATE_LIMIT_FILE)) { 79 | return; 80 | } 81 | fs.unlinkSync(RATE_LIMIT_FILE); 82 | } 83 | 84 | // Failure tracking 85 | 86 | type FailureData = { [matchId: string]: number }; 87 | class FailureInfo { 88 | private readonly data: FailureData; 89 | 90 | constructor() { 91 | this.data = this.getFailureData() ?? {} as FailureData 92 | } 93 | 94 | count(matchId: string): number { 95 | return this.data[matchId] ?? 0; 96 | } 97 | 98 | increment(matchId: string): number { 99 | const newCount = (this.count(matchId) ?? 0) + 1; 100 | this.data[matchId] = newCount; 101 | return newCount; 102 | } 103 | 104 | remove(matchId: string) { 105 | delete this.data[matchId]; 106 | } 107 | 108 | writeToDisk() { 109 | fs.writeFileSync(FAILURES_FILE, JSON.stringify(this.data)); 110 | } 111 | 112 | private getFailureData(): FailureData | null { 113 | if (!fs.existsSync(FAILURES_FILE)) { 114 | return null; 115 | } 116 | 117 | return JSON.parse(fs.readFileSync(FAILURES_FILE, 'utf8')) as FailureData; 118 | } 119 | }; 120 | 121 | // DO WORK SON 122 | 123 | async function loginIfNeeded() { 124 | if (!API.isLoggedIn()) { 125 | await API.loginWithSSO(SSO); 126 | } 127 | } 128 | 129 | function isResultError(e: ResultError | any): e is ResultError { 130 | return (e as ResultError).status === 'error'; 131 | } 132 | 133 | function getAlreadyDownloadedMatches() { 134 | const files: string[] = fs.readdirSync(OUTDIR); 135 | const results: StoredMatchData[] = []; 136 | files.forEach(file => { 137 | const matches = file.match(/match_(\d+)_(\d+)\.json/i); 138 | if (!matches) return; 139 | results.push({ matchId: matches[1], playerUnoId: matches[2] }); 140 | }); 141 | return results.reduce((memo: Record, item) => { 142 | const playerResults = memo[item.matchId] || []; 143 | playerResults.push(item); 144 | memo[item.matchId] = playerResults; 145 | return memo; 146 | }, {}); 147 | } 148 | 149 | async function downloadMatchesByBatch( 150 | matches: any[], 151 | playerMapping: PlayerMapping, 152 | mode: 'mp' | 'wz', 153 | previouslyDownloadedMatches: Record, 154 | failureInfo: FailureInfo 155 | ) { 156 | console.log(`downloadMatchesByBatch called for [${playerMapping.activisionTag}] [${mode}] [${matches.length}]`); 157 | let batch = []; 158 | for (let matchIdx = 0; matchIdx < matches.length; matchIdx++) { 159 | const match = matches[matchIdx]; 160 | const downloadedPlayers = previouslyDownloadedMatches[match.matchId]; 161 | const alreadyDownloadedForPlayer = 162 | downloadedPlayers && downloadedPlayers.find(it => it.playerUnoId === playerMapping.unoId) != null; 163 | if (!alreadyDownloadedForPlayer && failureInfo.count("" + match.matchId) < maxFailuresBeforeCutoff) { 164 | // console.log( 165 | // `[${match.matchId}] [${match.timestamp}] not already downloaded for [${playerMapping.unoId}] [${playerMapping.activisionTag}]` 166 | // ); 167 | batch.push(match); 168 | } else { 169 | // console.log( 170 | // `[${match.matchId}] already downloaded for [${playerMapping.unoId}]` 171 | // ); 172 | } 173 | 174 | if (batch.length == codApiBatchLimit * requestBatchLimit || matchIdx == matches.length - 1) { 175 | let batches = []; 176 | for (let batchIdx = 0; batchIdx < batch.length; batchIdx += codApiBatchLimit) { 177 | batches.push(batch.slice(batchIdx, batchIdx + codApiBatchLimit)); 178 | } 179 | batches = batches.filter(it => it.length > 0); 180 | if (batches.length > 0) { 181 | console.log(' fetching batch'); 182 | 183 | await Promise.all(batches.map(it => getMatches(playerMapping, mode, it))).then(resultBatches => { 184 | const counts = resultBatches.map(it => (isResultError(it) ? '-' : it.length)); 185 | console.log(` [${resultBatches.length}] [${counts.join(',')}] results`); 186 | resultBatches.forEach((resultBatch, batchIdx) => { 187 | if (isResultError(resultBatch)) { 188 | const batchForResult = batches[batchIdx]; 189 | console.error( 190 | `batch [${batchForResult[0].matchId} ${batchForResult[0].timestamp}]-[${ 191 | batchForResult[batchForResult.length - 1].matchId 192 | } ${batchForResult[batchForResult.length - 1].timestamp}] failed` 193 | ); 194 | console.error(resultBatch.error); 195 | return; 196 | } 197 | resultBatch.forEach((result, idx) => { 198 | const matchForResult = batches[batchIdx][idx]; 199 | if (isResultError(result)) { 200 | const failCount = failureInfo.increment("" + matchForResult.matchId); 201 | console.error(`[match-${matchForResult.matchId} ts-${matchForResult.timestamp}] failed (#${failCount})`); 202 | console.error(result.error); 203 | return; 204 | } 205 | fs.writeFileSync( 206 | `${OUTDIR}/match_${matchForResult.matchId}_${playerMapping.unoId}.json`, 207 | JSON.stringify(result.results) 208 | ); 209 | failureInfo.remove("" + matchForResult.matchId); 210 | // console.log(`downloaded [${matchForResult.matchId}] for [${playerMapping.unoId}]`); 211 | }); 212 | }); 213 | }); 214 | } 215 | batch = []; 216 | } 217 | } 218 | } 219 | 220 | async function getMatches( 221 | player: PlayerMapping, 222 | mode: 'mp' | 'wz', 223 | matches: { timestamp: number; matchId: string }[] 224 | ): Promise[]> { 225 | if (matches.length == 0) { 226 | return []; 227 | } 228 | 229 | const sortedMatches = matches.sort((a, b) => a.timestamp - b.timestamp); 230 | const firstMatch = sortedMatches[0]; 231 | const lastMatch = sortedMatches[sortedMatches.length - 1]; 232 | // console.log(`[${firstMatch.matchId} ${lastMatch.matchId}] start`); 233 | 234 | try { 235 | let allResults; 236 | switch (mode) { 237 | case 'mp': 238 | allResults = await API.MWcombatmpdate( 239 | player.activisionTag, 240 | firstMatch.timestamp, 241 | lastMatch.timestamp + 1, 242 | player.activisionPlatform 243 | ); 244 | break; 245 | case 'wz': 246 | allResults = await API.MWcombatwzdate( 247 | player.activisionTag, 248 | firstMatch.timestamp, 249 | lastMatch.timestamp + 1, 250 | player.activisionPlatform 251 | ); 252 | break; 253 | } 254 | 255 | const returnedMatches: any[] = allResults.matches ?? []; 256 | // console.log('------------ Requested'); 257 | // console.dir(sortedMatches.map(res => res.matchId)); 258 | // console.log('------------'); 259 | // console.log('------------ Returned'); 260 | // console.dir(returnedMatches.sort((a, b) => a.utcStartSeconds - b.utcStartSeconds).map(res => res.matchID)); 261 | // console.log('------------'); 262 | const results = matches.map>(it => { 263 | const returnedMatch = returnedMatches.find(res => res.matchID === it.matchId); 264 | if (returnedMatch) { 265 | return { status: 'ok', results: returnedMatch }; 266 | } else { 267 | return { 268 | status: 'error', 269 | error: `[${mode}] match not found [ts ${it.timestamp}] [id ${it.matchId}] [pid ${player.activisionPlatform} ${player.unoId} ${player.activisionTag}}]`, 270 | }; 271 | } 272 | }); 273 | 274 | // console.log(`[${firstMatch.matchId} ${lastMatch.matchId}] success`); 275 | return results; 276 | } catch (error) { 277 | // console.log(`[${firstMatch.matchId} ${lastMatch.matchId}] error`); 278 | return { status: 'error', error }; 279 | } 280 | } 281 | 282 | async function exhaustivelyRetrieveMatches( 283 | tag: string, 284 | platform: string, 285 | getMoreFn: (tag: string, start: number, end: number, platform: string) => Promise 286 | ) { 287 | let start = 0, 288 | end = 0; 289 | let results = []; 290 | let keepGoing = true; 291 | 292 | while (keepGoing) { 293 | let newResults = await getMoreFn(tag, start, end, platform); 294 | results = results.concat(newResults); 295 | if (0 <= newResults.length && newResults.length < codApiMatchResultLimit) { 296 | keepGoing = false; 297 | } else { 298 | end = newResults[newResults.length - 1].timestamp; 299 | } 300 | } 301 | return results; 302 | } 303 | 304 | async function main(mappings: PlayerMapping[]): Promise> { 305 | let results = { multiplayer: null, warzone: null } as MatchResults; 306 | 307 | try { 308 | for (let idx = 0; idx < mappings.length; idx++) { 309 | const player = mappings[idx]; 310 | const stats = await Promise.all([ 311 | // NOTE(jpr): disable MP until we figure out how we want to surface the data 312 | Promise.resolve([]), // exhaustivelyRetrieveMatches(player.activisionTag, player.activisionPlatform, API.MWfullcombatmpdate.bind(API)), 313 | exhaustivelyRetrieveMatches(player.activisionTag, player.activisionPlatform, API.MWfullcombatwzdate.bind(API)), 314 | ]); 315 | results.multiplayer = (results.multiplayer ?? []).concat(stats[0]); 316 | results.warzone = (results.warzone ?? []).concat(stats[1]); 317 | } 318 | } catch (error) { 319 | return { status: 'error', error } as Result; 320 | } 321 | 322 | return { status: 'ok', results } as Result; 323 | } 324 | 325 | /* 326 | * CLI handler 327 | */ 328 | 329 | (async () => { 330 | if (!COD_DATADIR) { 331 | console.error('Must set envvar COD_DATADIR'); 332 | process.exit(1); 333 | } 334 | if (!fs.statSync(COD_DATADIR).isDirectory) { 335 | console.error(`Data dir doesnt exist [${COD_DATADIR}]`); 336 | process.exit(1); 337 | } 338 | 339 | if (!SSO) { 340 | console.error('Must set envvar [COD_SSO]'); 341 | process.exit(1); 342 | } 343 | 344 | const rateLimitRemaining = rateLimitBackoffRemaining(); 345 | if (rateLimitRemaining > 0) { 346 | const remainingText = rateLimitRemaining < 60 ? '< 1' : Math.trunc(rateLimitRemaining/60); 347 | console.error(`Waiting [${remainingText}] more mins because of rate limiting`); 348 | process.exit(1); 349 | } 350 | 351 | try { 352 | if (!fs.existsSync(OUTDIR)) { 353 | fs.mkdirSync(OUTDIR, { recursive: true }); 354 | } 355 | 356 | await loginIfNeeded(); 357 | 358 | const playerNames = process.argv[2] ? [process.argv[2]] : Object.keys(playerMappings); 359 | 360 | const results = {} as any; 361 | const jobs = playerNames.map(name => { 362 | return new Promise(async (resolve, reject) => { 363 | const playerData = playerMappings[name]; 364 | if (!playerData) { 365 | return reject(`No player found for [${name}]`); 366 | } 367 | const data = await main(playerData); 368 | if (data.status === 'error') { 369 | return reject(data.error); 370 | } 371 | results[name] = data.results; 372 | return resolve(); 373 | }); 374 | }); 375 | await Promise.all(jobs); 376 | 377 | const previouslyDownloadedMatches = getAlreadyDownloadedMatches(); 378 | const failureInfo = new FailureInfo(); 379 | 380 | for (let nameIdx = 0; nameIdx < playerNames.length; nameIdx++) { 381 | const name = playerNames[nameIdx]; 382 | const mappings = playerMappings[name]; 383 | for (let mappingIdx = 0; mappingIdx < mappings.length; mappingIdx++) { 384 | const playerMapping = mappings[mappingIdx]; 385 | // NOTE(jpr): disable MP until we figure out how we want to surface the data 386 | // await downloadMatchesByBatch(results[name].multiplayer, playerMapping, 'mp', previouslyDownloadedMatches); 387 | await downloadMatchesByBatch(results[name].warzone, playerMapping, 'wz', previouslyDownloadedMatches, failureInfo); 388 | } 389 | } 390 | 391 | failureInfo.writeToDisk(); 392 | deleteRateLimitInfo(); 393 | } catch (err) { 394 | console.log('--------------------------------------------------------------------------------'); 395 | console.error('ERROR:'); 396 | console.error(err); 397 | if (isRateLimitError(err)) { 398 | writeNewRateLimitInfo(); 399 | } 400 | process.exit(1); 401 | } 402 | })(); 403 | -------------------------------------------------------------------------------- /frontend/resources/scripts/index.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const deepFetch = (obj, keyPath) => { 3 | for (let idx = 0; idx < keyPath.length; idx++) { 4 | const key = keyPath[idx]; 5 | obj = obj[key]; 6 | if (!obj) { 7 | break; 8 | } 9 | } 10 | return obj; 11 | }; 12 | 13 | const roundTo2 = (number) => { 14 | return Math.round(100 * number) / 100; 15 | }; 16 | 17 | const ONE_DAY = 24 * 3600 * 1000; 18 | const splitKey = '--'; 19 | const statDropdownOptions = [ 20 | { 21 | dataBucket: 'days', 22 | dropdownValue: 'kdratio', dropdownText: 'Raw K/D', chartTitle: 'Raw K/D', 23 | statResolver: (data) => { 24 | const kills = deepFetch(data, ['cumalative', 'kills']); 25 | const deaths = deepFetch(data, ['cumalative', 'deaths']); 26 | if (kills == null && deaths == null) return null; 27 | if ((kills || 0) + (deaths || 0) == 0) return 0; 28 | return (kills || 0) / ((deaths || 0) == 0 ? 1 : deaths); 29 | }, 30 | }, 31 | { 32 | dataBucket: 'days', 33 | dropdownValue: 'recentkddays', dropdownText: 'Smoothed K/D (days)', chartTitle: 'Smoothed K/D (past 7 days)', 34 | statResolver: (data) => { 35 | const kills = deepFetch(data, ['smoothed_7', 'kills']); 36 | const deaths = deepFetch(data, ['smoothed_7', 'deaths']); 37 | if (kills == null && deaths == null) return null; 38 | if ((kills || 0) + (deaths || 0) == 0) return 0; 39 | return (kills || 0) / ((deaths || 0) == 0 ? 1 : deaths); 40 | } 41 | }, 42 | { 43 | dataBucket: 'games', 44 | dropdownValue: 'recentkdgames', dropdownText: 'Smoothed K/D (games)', chartTitle: 'Smoothed K/D (past 25 games)', 45 | statResolver: (data) => { 46 | const kills = deepFetch(data, ['smoothed_25', 'kills']); 47 | const deaths = deepFetch(data, ['smoothed_25', 'deaths']); 48 | if (kills == null && deaths == null) return null; 49 | if ((kills || 0) + (deaths || 0) == 0) return 0; 50 | return (kills || 0) / ((deaths || 0) == 0 ? 1 : deaths); 51 | } 52 | }, 53 | { 54 | dataBucket: 'days', 55 | dropdownValue: 'killspergame', dropdownText: 'Kills / Game', chartTitle: 'Kills Per Game', 56 | statResolver: (data) => { 57 | const kills = deepFetch(data, ['cumalative', 'kills']); 58 | const matches = deepFetch(data, ['cumalative', 'matchesPlayed']); 59 | if (kills == null && matches == null) return null; 60 | if ((matches || 0) == 0) return 0; 61 | return (kills || 0.0) / (matches || 0.0); 62 | } 63 | }, 64 | { 65 | dataBucket: 'days', 66 | dropdownValue: 'deathspergame', dropdownText: 'Deaths / Game', chartTitle: 'Deaths Per Game', 67 | statResolver: (data) => { 68 | const deaths = deepFetch(data, ['cumalative', 'deaths']); 69 | const matches = deepFetch(data, ['cumalative', 'matchesPlayed']); 70 | if (deaths == null && matches == null) return null; 71 | if ((matches || 0) == 0) return 0; 72 | return (deaths || 0.0) / (matches || 0.0); 73 | } 74 | }, 75 | { 76 | dataBucket: 'days', 77 | dropdownValue: 'scorepermin', dropdownText: 'Score / Minute', chartTitle: 'Score Per Min', 78 | statPath: ['raw', 'scorePerMinute'].join(splitKey) 79 | }, 80 | { 81 | dataBucket: 'days', 82 | dropdownValue: 'gulagwinpercent', dropdownText: 'Gulag Win %', chartTitle: 'Gulag Win %', 83 | statResolver: (data) => { 84 | const wins = deepFetch(data, ['cumalative', 'gulagKills']); 85 | const losses = deepFetch(data, ['cumalative', 'gulagDeaths']); 86 | if (wins == null && losses == null) return null; 87 | if ((wins || 0) + (losses || 0) == 0) return 0; 88 | return 100.0 * (wins || 0.0) / ((wins || 0.0) + (losses || 0.0)); 89 | }, 90 | }, 91 | { 92 | dataBucket: 'days', 93 | dropdownValue: 'dmgpergame', dropdownText: 'Damage / Game', chartTitle: 'Damage Per Game', 94 | statResolver: (data) => { 95 | const dmg = deepFetch(data, ['cumalative', 'damageDone']); 96 | const matches = deepFetch(data, ['cumalative', 'matchesPlayed']); 97 | if (dmg == null && matches == null) return null; 98 | if ((matches || 0) == 0) return 0; 99 | return (dmg || 0.0) / (matches || 0.0); 100 | }, 101 | }, 102 | { 103 | dataBucket: 'days', 104 | dropdownValue: 'dmgperkill', dropdownText: 'Damage / Kill', chartTitle: 'Damage Per Kill', 105 | statResolver: (data) => { 106 | const dmg = deepFetch(data, ['cumalative', 'damageDone']); 107 | const kills = deepFetch(data, ['cumalative', 'kills']); 108 | if (dmg == null && kills == null) return null; 109 | if ((kills || 0) == 0) return 0; 110 | return (dmg || 0.0) / (kills || 0.0); 111 | }, 112 | }, 113 | { 114 | dataBucket: 'days', 115 | dropdownValue: 'monsterpercent', dropdownText: 'Monster Game %', chartTitle: 'Monster Game %', 116 | statResolver: (data) => { 117 | const monsters = deepFetch(data, ['cumalative', 'monsters']); 118 | const matches = deepFetch(data, ['cumalative', 'matchesPlayed']); 119 | if (monsters == null && matches == null) return null; 120 | if ((matches || 0) == 0) return 0; 121 | return 100.0 * (monsters || 0.0) / (matches || 0.0); 122 | }, 123 | }, 124 | { 125 | dataBucket: 'days', 126 | dropdownValue: 'gooseeggpercent', dropdownText: 'Goose Egg %', chartTitle: 'Goose Egg %', 127 | statResolver: (data) => { 128 | const gooseeggs = deepFetch(data, ['cumalative', 'gooseeggs']); 129 | const matches = deepFetch(data, ['cumalative', 'matchesPlayed']); 130 | if (gooseeggs == null && matches == null) return null; 131 | if ((matches || 0) == 0) return 0; 132 | return 100.0 * (gooseeggs || 0.0) / (matches || 0.0); 133 | }, 134 | }, 135 | ]; 136 | 137 | let _chart = null; 138 | let _queryParams = null; 139 | 140 | let _playerNames = []; 141 | let _corePlayerNames = []; 142 | let _seasonsInfo = null; 143 | let _metaInfo = null 144 | 145 | const loadInitialData = async () => { 146 | const data = await Promise.all([ 147 | fetch("/data/output/players.json").then(it => it.text()), 148 | fetch("/data/output/meta.json").then(it => it.text()), 149 | fetch("/data/output/seasons.json").then(it => it.text()), 150 | ]); 151 | _playerNames = JSON.parse(data[0]); 152 | _corePlayerNames = _playerNames.filter(it => it.isCore).map(it => it.name); 153 | _metaInfo = JSON.parse(data[1]); 154 | _seasonsInfo = JSON.parse(data[2]); 155 | }; 156 | 157 | const hideEmptyModeMessage = () => { 158 | document.querySelector('#empty-mode-message').style.display = 'none'; 159 | }; 160 | const setEmptyMessage = (msg) => { 161 | const el = document.querySelector('#empty-mode-message'); 162 | el.innerHTML = msg; 163 | el.style.display = 'absolute'; 164 | }; 165 | 166 | const fetchDataByTime = async (name, seasonid) => { 167 | const path = `/data/output/${name.toLowerCase()}_${seasonid}_time_wz.json`; 168 | return _fetchAndParseData(path); 169 | }; 170 | const fetchDataByGame = async (name, seasonid) => { 171 | const path = `/data/output/${name.toLowerCase()}_${seasonid}_game_wz.json`; 172 | return _fetchAndParseData(path); 173 | }; 174 | 175 | const _fetchAndParseData = async (path) => { 176 | const data = await fetch(path).then(it => it.text()).then(it => JSON.parse(it)); 177 | 178 | return data.sort((a, b) => { 179 | return a.date.localeCompare(b.date); 180 | }).filter(it => it.stats != null).map(it => { 181 | const dt = new Date(it.date); 182 | const stats = it.stats; 183 | return { dt, stats }; 184 | }); 185 | }; 186 | 187 | const setSeriesByBakedStat = async (statName, timeframe) => { 188 | const config = statDropdownOptions.find(it => it.dropdownValue == statName); 189 | if (config == null || (config.statPath == null && config.statResolver == null)) { 190 | setEmptyMessage(`No data found for [${statName}]`); 191 | return; 192 | } 193 | 194 | if (config.statPath) { 195 | await setSeriesByStat(config.statPath, config.dataBucket, timeframe); 196 | } else { 197 | await _setSeriesByCustomStat(config.statResolver, config.dataBucket, timeframe); 198 | } 199 | _chart.setTitle({ text: `Comparing ${_seasonsInfo.seasons.find(it => it.id == timeframe).desc}
[${config.chartTitle}]` }, null, false); 200 | _chart.redraw(false); 201 | _chart.reflow(); 202 | }; 203 | 204 | const setSeriesByStat = async (statName, dataBucket, timeframe) => { 205 | const resolveStat = (graph, keys) => { 206 | const res = graph[keys[0]]; 207 | if (!res) { 208 | return null; 209 | } 210 | if (keys.length == 1) { 211 | return res 212 | } 213 | return resolveStat(res, keys.slice(1)); 214 | }; 215 | 216 | const statKeyPath = statName.split(splitKey); 217 | await _setSeriesByCustomStat(it => resolveStat(it, statKeyPath), dataBucket, timeframe); 218 | 219 | _chart.setTitle({ text: `Comparing
[${statName}]` }, null, false); 220 | _chart.redraw(false); 221 | _chart.reflow(); 222 | }; 223 | 224 | const fetchData = (playerName, dataBucket, timeframe) => { 225 | if (dataBucket === 'days') { 226 | return fetchDataByTime(playerName, timeframe); 227 | } else if (dataBucket === 'games') { 228 | return fetchDataByGame(playerName, timeframe); 229 | } else { 230 | return Promise.error(`unknown databucket [${dataBucket}]`); 231 | } 232 | } 233 | 234 | const _setSeriesByCustomStat = async (mappingFn, dataBucket, timeframe) => { 235 | setEmptyMessage('Loading...'); 236 | const dataByUser = await Promise.all( 237 | _playerNames.map(it => fetchData(it.name, dataBucket, timeframe)) 238 | ); 239 | hideEmptyModeMessage(); 240 | 241 | const visibleNames = _queryParams.get('names') ? _queryParams.get('names').split(splitKey) : _corePlayerNames; 242 | 243 | dataByUser.map((data, idx) => { 244 | let d = dataBucket == 'days' ? data.map(it => { return { x: (new Date(it.dt)).getTime(), y: mappingFn(it.stats) }; }) : data.map((it, idx) => { return { x: idx - data.length + 1, y: mappingFn(it.stats) }; }); 245 | d = d.map(it => [it.x, it.y]); 246 | _chart.addSeries({ 247 | name: _playerNames[idx].name, 248 | visible: visibleNames.includes(_playerNames[idx].name), 249 | data: d, 250 | }, false, false); 251 | }); 252 | configureChart(dataBucket, timeframe); 253 | }; 254 | 255 | const configureChart = (dataBucket, timeframe) => { 256 | if (dataBucket == 'games') { 257 | _chart.xAxis[0].update({ 258 | type: 'spline', 259 | min: -30, 260 | labels: { enabled: false }, 261 | }, true); 262 | } else { 263 | const season = _seasonsInfo.seasons.find(it => it.id == timeframe); 264 | const endDate = new Date(season.end) > new Date() ? new Date() : new Date(season.end); 265 | _chart.xAxis[0].update({ 266 | type: 'datetime', 267 | min: endDate - (30 * ONE_DAY), 268 | tickInterval: ONE_DAY * 7, 269 | labels: { 270 | formatter: function () { 271 | const dt = new Date(this.value).toLocaleDateString('en-US', { month: "short", day: "numeric" }); 272 | return `${dt}`; 273 | }, 274 | step: 1, 275 | rotation: -45, 276 | }, 277 | }, true); 278 | } 279 | }; 280 | 281 | const buildChart = () => { 282 | return Highcharts.chart('container', { 283 | chart: { 284 | type: "spline", 285 | height: 400, 286 | panning: true, 287 | panKey: 'shift', 288 | zoomType: 'x', 289 | // displayErrors: true, 290 | }, 291 | title: { 292 | text: null, 293 | }, 294 | legend: { 295 | align: "center", 296 | verticalAlign: "bottom", 297 | layout: "horizontal", 298 | itemStyle: { 299 | color: Highcharts.getOptions().colors[0], 300 | fontSize: "0.925rem" 301 | }, 302 | itemHoverStyle: { 303 | color: Highcharts.getOptions().colors[0], 304 | } 305 | }, 306 | tooltip: { 307 | crosshairs: !0, 308 | formatter: function (tooltip) { 309 | const val = Highcharts.numberFormat(this.y, 2); 310 | if (_chart.xAxis[0].type == 'datetime') { 311 | const dt = new Date(this.x).toLocaleDateString('en-US', { month: "short", day: "numeric" }); 312 | return `${dt}
${this.point.series.name}: ${val}`; 313 | } else { 314 | return `${this.point.series.name}: ${val}`; 315 | } 316 | }, 317 | }, 318 | plotOptions: { 319 | series: { 320 | lineWidth: 3.5, 321 | marker: { 322 | enabled: !1 323 | }, 324 | events: { 325 | legendItemClick: function (e) { 326 | setTimeout(() => { 327 | const url = new URL(window.location); 328 | const params = url.searchParams; 329 | const visibleSeries = _chart.series.filter(it => it.visible); 330 | if (visibleSeries.length == 0 || visibleSeries.length == _playerNames.length) { 331 | params.delete('names'); 332 | } else { 333 | const joined = visibleSeries.map(it => it.name).join(splitKey); 334 | params.set('names', joined); 335 | } 336 | history.replaceState(null, '', url); 337 | _queryParams = params; 338 | }, 10); 339 | }, 340 | }, 341 | }, 342 | }, 343 | time: { 344 | useUTC: !1 345 | }, 346 | xAxis: { 347 | title: { text: null, }, 348 | scrollbar: { enabled: true }, 349 | }, 350 | yAxis: { 351 | title: { 352 | text: null, 353 | }, 354 | }, 355 | }); 356 | }; 357 | 358 | const configureStatDropdown = () => { 359 | const dropdown = document.querySelector('.stat-dropdown'); 360 | 361 | let option = document.createElement('option'); 362 | statDropdownOptions.forEach(config => { 363 | option = document.createElement('option'); 364 | option.value = config.dropdownValue; 365 | option.innerHTML = config.dropdownText; 366 | option.selected = _queryParams.get('stat') == config.dropdownValue; 367 | dropdown.appendChild(option); 368 | }); 369 | 370 | return dropdown; 371 | }; 372 | 373 | const configureSeasonDropdown = () => { 374 | const dropdown = document.querySelector('.season-dropdown'); 375 | 376 | let option = document.createElement('option'); 377 | _seasonsInfo.seasons.forEach(config => { 378 | option = document.createElement('option'); 379 | option.value = config.id; 380 | option.innerHTML = config.id == _seasonsInfo.current ? config.desc + ' (current)' : config.desc; 381 | option.selected = _queryParams.get('timeframe') == config.id; 382 | dropdown.appendChild(option); 383 | }); 384 | 385 | return dropdown; 386 | }; 387 | 388 | const populateRecordsTable = async (selector, filename) => { 389 | const container = document.querySelector(selector); 390 | const path = `/data/output/${filename}.json`; 391 | const data = await fetch(path).then(it => it.text()).then(it => JSON.parse(it)); 392 | 393 | const cardWithConfig = (title, value, dateText, playerName) => { 394 | const imgPart = playerName == null ? '' : ``; 395 | const html = ` 396 |
${title}
397 |
${value}
398 |
${imgPart}${dateText}
399 | `; 400 | 401 | const card = document.createElement('div'); 402 | card.className = 'card'; 403 | card.innerHTML = html; 404 | 405 | return card; 406 | }; 407 | 408 | data.forEach(rowData => { 409 | const records = []; 410 | const addedNames = new Set(); 411 | let bestValue = null; 412 | rowData.meta.forEach(meta => { 413 | if ((bestValue == null || meta.value == bestValue) && !addedNames.has(meta.player_id)) { 414 | records.push(meta); 415 | addedNames.add(meta.player_id); 416 | bestValue = meta.value; 417 | } 418 | }); 419 | if (records.length == 1) { 420 | const meta = records[0]; 421 | let dateText = ''; 422 | 423 | const isSameDay = (dt1, dt2) => { 424 | const config = { year: 'numeric', month: 'short', day: 'numeric' }; 425 | const d1 = new Date(dt1).toLocaleString('en-US', config); 426 | const d2 = new Date(dt2).toLocaleString('en-US', config); 427 | return d1 === d2; 428 | }; 429 | 430 | if (meta.date_key != null) { 431 | const config = { month: 'short', day: 'numeric' }; 432 | const dt = new Date(meta.date_key).toLocaleDateString('en-US', config); 433 | if (meta.until_date_key == null || isSameDay(meta.date_key, meta.until_date_key)) { 434 | dateText = ` (${dt})`; 435 | } else { 436 | const untilDt = new Date(meta.until_date_key).toLocaleDateString('en-US', config); 437 | dateText = ` (${dt} - ${untilDt})`; 438 | } 439 | } 440 | 441 | let card = cardWithConfig(rowData.title, meta.value, meta.player_id + dateText, meta.player_id); 442 | container.appendChild(card); 443 | } else { 444 | const names = Array.from(addedNames).sort((a, b) => a.localeCompare(b)).join(' / '); 445 | let card = cardWithConfig(rowData.title, records[0].value, names); 446 | container.appendChild(card); 447 | } 448 | }); 449 | }; 450 | 451 | const populateGameRecordsTable = () => populateRecordsTable('.records-bygame-table-container .records', 'leaderboard_bygame'); 452 | const populateLifetimeRecordsTable = () => populateRecordsTable('.records-lifetime-table-container .records', 'leaderboard_lifetime'); 453 | 454 | const populateTeamRecordsTable = async () => { 455 | const container = document.querySelector('.teamrecords'); 456 | const path = '/data/output/team_leaderboards.json'; 457 | const data = await fetch(path).then(it => it.text()).then(it => JSON.parse(it)).then(it => it.sort((a, b) => a.sortOrder - b.sortOrder)); 458 | 459 | const cardWithConfig = (mode, teamStats) => { 460 | let html = ` 461 |
${mode}
462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | ` 478 | 479 | teamStats.forEach(it => { 480 | html += ` 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | `; 493 | }); 494 | html += ` 495 | 496 |
TeamAvg
Place
Avg
Kills
Avg
Dmg
Max
Kills
Max
Dmg
Max
Deaths
GamesWins
${it.player_ids.split(',').join(' / ')}${it.avgPlacement}${it.avgKills}${it.avgDmg}${it.maxKills}${it.maxDmg}${it.maxDeaths}${it.numGames}${it.numWins}
497 | `; 498 | 499 | const card = document.createElement('div'); 500 | card.className = 'card'; 501 | card.innerHTML = html; 502 | 503 | const el = card.querySelector('.sortable-table'); 504 | new Tablesort(el, { 505 | descending: true 506 | }); 507 | 508 | return card; 509 | }; 510 | 511 | data.forEach(rowData => { 512 | const first = rowData.stats[0]; 513 | let card = cardWithConfig(rowData.mode, rowData.stats); 514 | container.appendChild(card); 515 | }); 516 | }; 517 | 518 | const populateRecentMatches = async () => { 519 | const container = document.querySelector('.matches'); 520 | const path = '/data/output/recent_matches.json'; 521 | const data = await fetch(path).then(it => it.text()).then(it => JSON.parse(it)); 522 | 523 | const cardWithConfig = (dateText, modeText, playerText, placementText, numTeams, numKills, numDamage) => { 524 | const isWin = placementText == 1; 525 | placementText = placementText.toString(); 526 | switch (placementText) { 527 | case '11': 528 | case '12': 529 | case '13': 530 | placementText = `${placementText}th`; 531 | break; 532 | default: 533 | switch (placementText[placementText.length - 1]) { 534 | case '1': 535 | placementText = `${placementText}st`; 536 | break; 537 | case '2': 538 | placementText = `${placementText}nd`; 539 | break; 540 | case '3': 541 | placementText = `${placementText}rd`; 542 | break; 543 | default: 544 | placementText = `${placementText}th`; 545 | break; 546 | } 547 | break; 548 | } 549 | const html = ` 550 |

${dateText}

551 |

${modeText}

552 |
${placementText} / ${numTeams}
553 |

${playerText}

554 |
555 |
556 |

${numKills}

557 |

${numKills == 1 ? 'Kill' : 'Kills'}

558 |
559 |
560 |

${numDamage}

561 |

Damage

562 |
563 |
564 | `; 565 | 566 | const card = document.createElement('div'); 567 | card.className = isWin ? 'card card-winner' : 'card'; 568 | card.innerHTML = html; 569 | 570 | return card; 571 | }; 572 | 573 | data.forEach(rowData => { 574 | const dt = new Date(rowData.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }); 575 | const placement = rowData.player_stats[0].stats.teamPlacement; 576 | const numTeams = rowData.player_stats[0].stats.numberOfTeams; 577 | const kills = rowData.player_stats.reduce((memo, item) => { 578 | return memo + item.stats.kills; 579 | }, 0); 580 | const dmg = rowData.player_stats.reduce((memo, item) => { 581 | return memo + item.stats.damageDone; 582 | }, 0); 583 | 584 | const card = cardWithConfig(dt, rowData.game_mode, rowData.player_ids.split(',').join(' / '), placement, numTeams, kills, dmg); 585 | container.appendChild(card); 586 | }); 587 | }; 588 | 589 | const populateRecentSessions = async () => { 590 | const container = document.querySelector('.sessions'); 591 | const path = '/data/output/recent_sessions.json'; 592 | const data = await fetch(path).then(it => it.text()).then(it => JSON.parse(it)); 593 | 594 | const cardWithConfig = (playerText, numGames, numWins, top5s, top10s, gulagWins, gulagLosses, numKills, numDeaths, damageDone, maxKills, maxDamage) => { 595 | const kdText = roundTo2(numDeaths == 0 ? numKills : numKills / numDeaths); 596 | const html = ` 597 |
598 |

${playerText}

599 |

${numGames} ${numGames == 1 ? 'Game' : 'Games'}

600 |
601 |
602 |
603 |

${numWins}

604 |

${numWins == 1 ? 'Win' : 'Wins'}

605 |
606 |
607 |

${top5s}

608 |

${top5s == 1 ? 'Top 5' : 'Top 5s'}

609 |
610 |
611 |

${top10s}

612 |

${top10s == 1 ? 'Top 10' : 'Top 10s'}

613 |
614 |
615 |
616 |
617 |

${roundTo2(numKills / numGames)} / ${maxKills}

618 |

Kills (avg/best)

619 |
620 |
621 |

${Math.trunc(damageDone / numGames)} / ${maxDamage}

622 |

Damage (avg/best)

623 |
624 |
625 |
626 |
627 |

${kdText}

628 |

K/D

629 |
630 |
631 |

${roundTo2(100 * (gulagLosses == 0 ? 1 : gulagWins / (gulagLosses + gulagWins)))}%

632 |

Gulag Win %

633 |
634 |
635 | `; 636 | 637 | const card = document.createElement('div'); 638 | card.className = 'card' 639 | card.innerHTML = html; 640 | 641 | return card; 642 | }; 643 | 644 | data.filter(it => _corePlayerNames.map(it => it.toLowerCase()).includes(it.player_id)).forEach(rowData => { 645 | const s = rowData.stats; 646 | const card = cardWithConfig(rowData.player_id, s.numGames, s.wins, s.top5, s.top10, s.gulagKills, s.gulagDeaths, s.kills, s.deaths, s.damageDone, s.maxKills, s.maxDamage); 647 | container.appendChild(card); 648 | }); 649 | }; 650 | 651 | const initialize = async () => { 652 | _queryParams = new URLSearchParams(window.location.search); 653 | if (window.location.search == "") { 654 | let params = '?mode=by-stat&stat=kdratio&timeframe=lifetime'; 655 | if (_queryParams.get('names')) { 656 | params += `&names=${_queryParams.get('names')}`; 657 | } 658 | _queryParams = new URLSearchParams(params); 659 | } 660 | 661 | await loadInitialData(); 662 | const updatedAt = new Date(_metaInfo.updatedAt); 663 | const formatted = updatedAt.toLocaleString('en-US', { hour: "numeric", minute: "numeric" }); 664 | document.querySelector('.last-updated-text').innerHTML = `Last Updated: ${formatted}`; 665 | 666 | _chart = buildChart(); 667 | 668 | const redirect = (stat, timeframe) => { 669 | stat = stat ? stat : 'kdratio'; 670 | timeframe = timeframe ? timeframe : 'lifetime'; 671 | let url = `/index.html?mode=by-stat&stat=${stat}&timeframe=${timeframe}`; 672 | if (_queryParams.get('names')) { 673 | url += `&names=${_queryParams.get('names')}`; 674 | } 675 | window.location = url; 676 | }; 677 | 678 | const statDropdown = configureStatDropdown(); 679 | statDropdown.addEventListener('change', e => { 680 | redirect(e.target.value, _queryParams.get('timeframe')); 681 | }); 682 | 683 | const seasonDropdown = configureSeasonDropdown(); 684 | seasonDropdown.addEventListener('change', e => { 685 | redirect(_queryParams.get('stat'), e.target.value); 686 | }); 687 | 688 | populateGameRecordsTable(); 689 | populateLifetimeRecordsTable(); 690 | populateTeamRecordsTable(); 691 | populateRecentMatches(); 692 | populateRecentSessions(); 693 | 694 | switch (_queryParams.get('mode')) { 695 | case 'by-stat': { 696 | setSeriesByBakedStat(_queryParams.get('stat'), _queryParams.get('timeframe')); 697 | break; 698 | } 699 | default: { 700 | console.warn(`unknown mode [${_queryParams.get('mode')}]`); 701 | break; 702 | } 703 | } 704 | }; 705 | 706 | window.initialize = initialize; 707 | })(); 708 | -------------------------------------------------------------------------------- /parser/parse_matches.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly sourcedir="${COD_DATADIR}/fetcher/output" 6 | readonly outdir="${COD_DATADIR}/parser/output" 7 | 8 | readonly dbfile="${outdir}/data.sqlite" 9 | readonly dbcommandsfile="${outdir}/_commands.sql" 10 | 11 | readonly debug_out=false 12 | 13 | die() { 14 | local -r msg="${1}" 15 | 16 | echo "ERROR: ${msg}" 17 | exit 1 18 | } 19 | 20 | ### SQL 21 | 22 | write_sql_start() { 23 | cat <<-EOF > "${dbcommandsfile}" 24 | BEGIN TRANSACTION; 25 | 26 | EOF 27 | } 28 | 29 | write_sql_end() { 30 | cat <<-EOF >> "${dbcommandsfile}" 31 | COMMIT; 32 | EOF 33 | } 34 | 35 | commit_sql() { 36 | sqlite3 "${dbfile}" <<-EOF 37 | .read ${dbcommandsfile} 38 | EOF 39 | rm "${dbcommandsfile}" 40 | } 41 | 42 | create_tables() { 43 | sqlite3 "${dbfile}" <<-EOF 44 | CREATE TABLE IF NOT EXISTS players( 45 | player_uno_id TEXT PRIMARY KEY UNIQUE, 46 | player_id TEXT NOT NULL, 47 | is_core BOOLEAN NOT NULL DEFAULT 0 CHECK(is_core IN(0, 1)) 48 | ); 49 | 50 | CREATE TABLE IF NOT EXISTS raw_games( 51 | game_id TEXT NOT NULL, 52 | player_uno_id TEXT NOT NULL, 53 | stats BLOB, 54 | 55 | PRIMARY KEY (game_id, player_uno_id), 56 | FOREIGN KEY (player_uno_id) 57 | REFERENCES players (player_uno_id) 58 | ON DELETE CASCADE 59 | ON UPDATE NO ACTION 60 | ); 61 | 62 | -- NOTE(jpr): this table exists mainly as a json parsing / normalization cache. ideally it would be a view, but when I 63 | -- tested that out, performance was a lot worse (~100x slower). so I'm taking an in-between approach and dropping then 64 | -- recreating the table on each runthrough. 65 | DROP TABLE IF EXISTS wz_valid_games; 66 | CREATE TABLE IF NOT EXISTS wz_valid_games( 67 | date_key TEXT NOT NULL, 68 | game_mode TEXT NOT NULL CHECK(game_mode IN ('mp', 'wz')), 69 | game_mode_sub TEXT NOT NULL, 70 | game_id TEXT NOT NULL, 71 | player_uno_id TEXT NOT NULL, 72 | numberOfPlayers INTEGER NOT NULL CHECK(numberOfPlayers > 0), 73 | numberOfTeams INTEGER NOT NULL CHECK(numberOfTeams > 0), 74 | 75 | score INTEGER NOT NULL, 76 | scorePerMinute REAL NOT NULL, 77 | kills INTEGER NOT NULL, 78 | deaths INTEGER NOT NULL, 79 | damageDone INTEGER NOT NULL, 80 | damageTaken INTEGER NOT NULL, 81 | gulagKills INTEGER NOT NULL, 82 | gulagDeaths INTEGER NOT NULL, 83 | teamPlacement INTEGER NOT NULL CHECK(teamPlacement > 0), 84 | kdRatio REAL NOT NULL, 85 | distanceTraveled REAL NOT NULL, 86 | headshots INTEGER NOT NULL, 87 | objectiveBrCacheOpen INTEGER NOT NULL, 88 | objectiveReviver INTEGER NOT NULL, 89 | objectiveBrDownAll INTEGER NOT NULL, 90 | objectiveDestroyedVehicleAll INTEGER NOT NULL, 91 | stats BLOB NOT NULL, 92 | 93 | PRIMARY KEY (game_id, player_uno_id), 94 | FOREIGN KEY (player_uno_id) 95 | REFERENCES players (player_uno_id) 96 | ON DELETE CASCADE 97 | ON UPDATE NO ACTION 98 | ); 99 | 100 | DROP VIEW IF EXISTS vw_game_modes; 101 | CREATE VIEW vw_game_modes AS 102 | SELECT * FROM ( 103 | WITH cte_game_modes( 104 | id, mode, category, display_name, is_plunder, is_stimulus, wz_track_stats) AS ( 105 | VALUES 106 | ('br_dmz_104', 'wz', 'wz_plunder', 'Blood Money', true, false, false), 107 | ('br_dmz_plnbld', 'wz', 'wz_plunder', 'Blood Money', true, false, false), 108 | ('br_dmz_85', 'wz', 'wz_plunder', 'Plunder Duos', true, false, false), 109 | ('br_dmz_plndtrios', 'wz', 'wz_plunder', 'Plunder Trios', true, false, false), 110 | ('br_dmz_38', 'wz', 'wz_plunder', 'Plunder Quads', true, false, false), 111 | ('br_dmz_76', 'wz', 'wz_plunder', 'Plunder Quads', true, false, false), 112 | ('br_dmz_plunquad', 'wz', 'wz_plunder', 'Plunder Quads', true, false, false), 113 | 114 | ('br_71', 'wz', 'wz_solo', 'Stim Solo', false, true, true), 115 | ('br_brbbsolo', 'wz', 'wz_solo', 'Stim Solo', false, true, true), 116 | ('br_brbbduo', 'wz', 'wz_duos', 'Stim Duos', false, true, true), 117 | ('br_brduostim_name2', 'wz', 'wz_duos', 'Stim Duos', false, true, true), 118 | ('br_brtriostim_name2', 'wz', 'wz_trios', 'Stim Trios', false, true, true), 119 | ('br_brbbquad', 'wz', 'wz_quads', 'Stim Quads', false, true, true), 120 | 121 | ('br_brsolo', 'wz', 'wz_solo', 'Solo', false, false, true), 122 | ('br_87', 'wz', 'wz_solo', 'Solo', false, false, true), 123 | ('br_brduos', 'wz', 'wz_duos', 'Duos', false, false, true), 124 | ('br_88', 'wz', 'wz_duos', 'Duos', false, false, true), 125 | ('br_brtrios', 'wz', 'wz_trios', 'Trios', false, false, true), 126 | ('br_25', 'wz', 'wz_trios', 'Trios', false, false, true), 127 | ('br_74', 'wz', 'wz_trios', 'Trios', false, false, true), 128 | ('br_brquads', 'wz', 'wz_quads', 'Quads', false, false, true), 129 | ('br_89', 'wz', 'wz_quads', 'Quads', false, false, true), 130 | ('br_br_quads', 'wz', 'wz_quads', 'Quads', false, false, true), 131 | 132 | ('br_jugg_brtriojugr', 'wz', 'wz_jugtrios', 'Jugg Trios', false, false, true), 133 | ('br_jugg_brquadjugr', 'wz', 'wz_jugquads', 'Jugg Quads', false, false, true), 134 | ('br_mini_miniroyale', 'wz', 'wz_mini', 'Mini Royale', false, false, true), 135 | ('br_brthquad', 'wz', 'wz_quads', 'Quads 200', false, false, true), 136 | ('br_br_real', 'wz', 'wz_realism', 'Realism BR', false, false, true), 137 | ('br_86', 'wz', 'wz_realism', 'Realism BR', false, false, true), 138 | ('br_brsolohwn', 'wz', 'wz_solo', 'Night Solo', false, false, true), 139 | ('br_brduohwn', 'wz', 'wz_duos', 'Night Duos', false, false, true), 140 | ('br_brhwntrios', 'wz', 'wz_trios', 'Night Trios', false, false, true), 141 | ('br_brhwnquad', 'wz', 'wz_quads', 'Night Quads', false, false, true), 142 | ('br_wsow_br_trios', 'wz', 'wz_trios', 'WSOW Trios', false, false, true), 143 | 144 | ('br_vg_royale_solo', 'wz', 'wz_solo', 'Vanguard Solo', false, false, true), 145 | ('br_vg_royale_duos', 'wz', 'wz_duos', 'Vanguard Duos', false, false, true), 146 | ('br_vg_royale_quads', 'wz', 'wz_quads', 'Vanguard Quads', false, false, true), 147 | 148 | ('br_77' , 'wz', 'wz_scopescatter', 'BR Scopes & Scattergun', false, false, false), 149 | ('brtdm_113', 'wz', 'wz_rumble', 'Warzone Rumble', false, false, false), 150 | ('br_kingslayer_kingsltrios', 'wz', 'wz_kingtrios', 'Kingslayer Trios', false, false, false), 151 | ('br_truckwar_trwarsquads', 'wz', 'wz_armoredquads', 'Armored Royale', false, true, false), 152 | ('br_zxp_zmbroy', 'wz', 'wz_zombietrios', 'Zombie Trios', false, true, false) 153 | ) 154 | SELECT * from cte_game_modes 155 | ) 156 | ; 157 | 158 | DROP VIEW IF EXISTS vw_seasons; 159 | CREATE VIEW vw_seasons AS 160 | SELECT * FROM ( 161 | WITH cte_seasons(id, desc, start, end, sort_order) AS ( 162 | VALUES 163 | ('lifetime', 'Lifetime', '1970-01-01T00:00:01Z', '2286-11-20T17:46:38Z', 1), 164 | ('season01', 'Season 1', '1970-01-01T00:00:01Z', '2020-02-11T17:59:59Z', 18), 165 | ('season02', 'Season 2', '2020-02-11T18:00:00Z', '2020-04-07T23:59:59Z', 17), 166 | ('season03', 'Season 3', '2020-04-08T00:00:00Z', '2020-06-11T02:59:59Z', 16), 167 | ('season04', 'Season 4', '2020-06-11T03:00:00Z', '2020-08-04T23:59:59Z', 15), 168 | ('season05', 'Season 5', '2020-08-05T00:00:00Z', '2020-09-28T23:59:59Z', 14), 169 | ('season06', 'Season 6', '2020-09-29T00:00:00Z', '2020-12-16T23:59:59Z', 13), 170 | ('season11', 'Season BO1', '2020-12-17T00:00:00Z', '2021-02-25T23:59:59Z', 12), 171 | ('season12', 'Season BO2', '2021-02-26T00:00:00Z', '2021-04-21T23:59:59Z', 11), 172 | ('season13', 'Season BO3', '2021-04-22T00:00:00Z', '2021-06-16T23:59:59Z', 10), 173 | ('season14', 'Season BO4', '2021-06-17T00:00:00Z', '2021-08-12T23:59:59Z', 9), 174 | ('season15', 'Season BO5', '2021-08-13T00:00:00Z', '2021-10-06T23:59:59Z', 8), 175 | ('season16', 'Season BO6', '2021-10-07T00:00:00Z', '2021-12-07T23:59:59Z', 7), 176 | ('season21', 'Season VG1', '2021-12-08T00:00:00Z', '2022-03-22T15:59:59Z', 6), 177 | ('season22', 'Season VG2', '2022-03-22T16:00:00Z', '2022-04-27T15:59:59Z', 5), 178 | ('season23', 'Season VG3', '2022-04-27T16:00:00Z', '2022-06-22T11:59:59Z', 4), 179 | ('season24', 'Season VG4', '2022-06-22T12:00:00Z', '2022-08-24T15:59:59Z', 3), 180 | ('season25', 'Season VG5', '2022-08-24T16:00:00Z', '2027-08-11T23:59:59Z', 2) 181 | ) 182 | SELECT * from cte_seasons 183 | ) 184 | ; 185 | 186 | DROP VIEW IF EXISTS vw_settings; 187 | CREATE VIEW vw_settings AS 188 | SELECT * FROM ( 189 | WITH cte_settings(id, desc, int_value) AS ( 190 | VALUES 191 | ('monsters', 'Monster game threshold', 192 | 8), 193 | ('session_delta_seconds', 'Amount of time between games for session detection', 194 | 2 * 60 * 60 ) -- 2 hours 195 | ) 196 | SELECT * from cte_settings 197 | ) 198 | ; 199 | 200 | 201 | DROP VIEW IF EXISTS vw_core_players; 202 | CREATE VIEW vw_core_players AS 203 | SELECT DISTINCT player_id FROM players WHERE is_core=1; 204 | 205 | DROP VIEW IF EXISTS vw_unknown_modes_wz; 206 | CREATE VIEW vw_unknown_modes_wz AS 207 | SELECT 208 | json_extract(stats, '$.mode') mode, 209 | strftime('%Y-%m-%dT%H:%M:%SZ', min(json_extract(stats, '$.utcEndSeconds')), 'unixepoch') firstSeen, 210 | strftime('%Y-%m-%dT%H:%M:%SZ', max(json_extract(stats, '$.utcEndSeconds')), 'unixepoch') lastSeen, 211 | count(1) totalGames 212 | FROM raw_games 213 | WHERE json_extract(stats, '$.gameType')='wz' AND mode NOT IN (SELECT id FROM vw_game_modes WHERE mode='wz') 214 | GROUP BY mode 215 | ORDER BY firstSeen desc; 216 | 217 | DROP VIEW IF EXISTS vw_unknown_modes_mp; 218 | CREATE VIEW vw_unknown_modes_mp AS 219 | SELECT DISTINCT json_extract(stats, '$.mode') mode 220 | FROM raw_games 221 | WHERE json_extract(stats, '$.gameType')='mp' AND mode NOT IN (SELECT id FROM vw_game_modes WHERE mode='mp'); 222 | 223 | DROP VIEW IF EXISTS vw_stats_wz; 224 | CREATE VIEW vw_stats_wz AS 225 | SELECT 226 | gs.date_key, 227 | gs.game_mode_sub, 228 | gs.game_id, 229 | p.player_id AS player_id, 230 | gs.numberOfPlayers, 231 | gs.numberOfTeams, 232 | 233 | gs.score, 234 | gs.scorePerMinute, 235 | gs.kills, 236 | gs.deaths, 237 | gs.damageDone, 238 | gs.damageTaken, 239 | gs.gulagKills, 240 | gs.gulagDeaths, 241 | gs.teamPlacement, 242 | gs.kdRatio, 243 | gs.distanceTraveled, 244 | gs.headshots, 245 | gs.objectiveBrCacheOpen, 246 | gs.objectiveReviver, 247 | gs.objectiveBrDownAll, 248 | gs.objectiveDestroyedVehicleAll, 249 | 250 | json_object( 251 | 'numberOfPlayers', gs.numberOfPlayers, 252 | 'numberOfTeams', gs.numberOfTeams, 253 | 'score', gs.score, 254 | 'scorePerMinute', gs.scorePerMinute, 255 | 'kills', gs.kills, 256 | 'deaths', gs.deaths, 257 | 'damageDone', gs.damageDone, 258 | 'damageTaken', gs.damageTaken, 259 | 'gulagKills', gs.gulagKills, 260 | 'gulagDeaths', gs.gulagDeaths, 261 | 'teamPlacement', gs.teamPlacement, 262 | 'kdRatio', gs.kdRatio, 263 | 'distanceTraveled', gs.distanceTraveled, 264 | 'headshots', gs.headshots, 265 | 'objectiveBrCacheOpen', gs.objectiveBrCacheOpen, 266 | 'objectiveReviver', gs.objectiveReviver, 267 | 'objectiveBrDownAll', gs.objectiveBrDownAll, 268 | 'objectiveDestroyedVehicleAll', gs.objectiveDestroyedVehicleAll 269 | ) stats 270 | FROM 271 | wz_valid_games gs 272 | JOIN 273 | players p on p.player_uno_id=gs.player_uno_id 274 | WHERE 275 | gs.game_mode='wz' AND 276 | gs.game_mode_sub IN (select id from vw_game_modes where wz_track_stats=true) AND 277 | 1 278 | ; 279 | 280 | -- NOTE(jpr): disable MP until we figure out how we want to surface the data 281 | -- DROP VIEW IF EXISTS vw_stats_mp; 282 | -- CREATE VIEW vw_stats_mp AS 283 | -- SELECT 284 | -- gs.date_key, 285 | -- gs.game_mode_sub, 286 | -- gs.game_id, 287 | -- p.player_id AS player_id, 288 | -- gs.stats 289 | -- FROM 290 | -- valid_games gs 291 | -- JOIN 292 | -- players p on p.player_uno_id=gs.player_uno_id 293 | -- WHERE 294 | -- gs.game_mode='mp' AND 295 | -- 1 296 | -- ; 297 | 298 | DROP VIEW IF EXISTS vw_player_sessions; 299 | CREATE VIEW vw_player_sessions AS 300 | WITH cte_deltas AS ( 301 | SELECT 302 | date_key, 303 | player_id, 304 | cast(strftime('%s', date_key) as int) - lag(cast(strftime('%s', date_key) as int)) over (partition by player_id order by date_key) as delta 305 | FROM vw_stats_wz 306 | ORDER BY date_key 307 | ), cte_session_detections AS ( 308 | SELECT 309 | vsw.date_key, 310 | cted.player_id, 311 | CASE 312 | WHEN ifnull(cted.delta, 9999999) >= (select int_value from vw_settings where id='session_delta_seconds') THEN 313 | 1 314 | ELSE 315 | 0 316 | END is_new_session 317 | FROM vw_stats_wz vsw 318 | JOIN cte_deltas cted on cted.date_key=vsw.date_key AND cted.player_id=vsw.player_id 319 | ORDER BY vsw.date_key 320 | ), cte_new_sessions AS( 321 | SELECT * from cte_session_detections where is_new_session=1 order by date_key 322 | ), cte_ordered_sessions AS( 323 | SELECT 324 | player_id, 325 | date_key start, 326 | strftime('%Y-%m-%dT%H:%M:%SZ', ifnull(lead(cast(strftime('%s', date_key) as int)) over (PARTITION by player_id order by date_key), 9999999999) - 1, 'unixepoch') end 327 | FROM cte_new_sessions 328 | ) 329 | 330 | SELECT 331 | player_id, 332 | ROW_NUMBER () OVER (PARTITION BY player_id ORDER BY start) session_number, 333 | player_id || '_' || ROW_NUMBER () OVER (PARTITION BY player_id ORDER BY start) session_id, 334 | start, 335 | end 336 | FROM cte_ordered_sessions 337 | ; 338 | 339 | DROP VIEW IF EXISTS vw_player_sessions_with_stats; 340 | CREATE VIEW vw_player_sessions_with_stats AS 341 | with cte_all_sessions AS ( 342 | select vsw.*, vps.session_id, vps.session_number, vps.start, vps.end from vw_stats_wz vsw join vw_player_sessions vps on 343 | vsw.date_key >= vps.start AND 344 | vsw.date_key < vps.end AND 345 | vsw.player_id = vps.player_id AND 346 | 1 347 | ) 348 | 349 | select player_id, session_id, session_number, start, end, json_object( 350 | 'numGames', count(1), 351 | 'kills', sum(kills), 352 | 'deaths', sum(deaths), 353 | 'damageDone', sum(damageDone), 354 | 'maxKills', max(kills), 355 | 'maxDamage', max(damageDone), 356 | 'gulagKills', sum(gulagKills), 357 | 'gulagDeaths', sum(gulagDeaths), 358 | 'wins', sum( 359 | case 360 | when teamPlacement <= 1 then 1 361 | else 0 362 | end 363 | ), 364 | 'top5', sum( 365 | case 366 | when teamPlacement <= 5 then 1 367 | else 0 368 | end 369 | ), 370 | 'top10', sum( 371 | case 372 | when teamPlacement <= 10 then 1 373 | else 0 374 | end 375 | ) 376 | ) stats from cte_all_sessions group by session_id order by player_id, start desc 377 | ; 378 | 379 | DROP VIEW IF EXISTS vw_full_game_stats; 380 | CREATE VIEW vw_full_game_stats AS 381 | WITH cte_recent_games AS ( 382 | select date_key, game_id from vw_stats_wz where player_id in (SELECT * FROM vw_core_players) group by game_id 383 | ) 384 | 385 | SELECT 386 | vsw.date_key, 387 | vsw.game_id, 388 | vsw.game_mode_sub, 389 | group_concat(vsw.player_id) player_ids, 390 | json_group_array(json_object('player_id', vsw.player_id, 'stats', json(vsw.stats))) player_stats 391 | FROM cte_recent_games crg 392 | JOIN vw_stats_wz vsw on vsw.game_id = crg.game_id 393 | GROUP BY crg.game_id 394 | ; 395 | 396 | DROP VIEW IF EXISTS vw_team_stat_breakdowns; 397 | CREATE VIEW vw_team_stat_breakdowns AS 398 | with cte_exploded AS ( 399 | select 400 | date_key, 401 | player_ids, 402 | game_id, 403 | game_mode_sub, 404 | vgm.category, 405 | value 406 | from vw_full_game_stats, json_each(vw_full_game_stats.player_stats) 407 | join vw_game_modes vgm on vgm.id=game_mode_sub 408 | order by game_id 409 | ), cte_summarized AS ( 410 | select date_key, game_id, game_mode_sub, category, player_ids, 411 | count(1) numPlayers, 412 | sum(json_extract(value, '$.stats.kills')) kills, 413 | sum(json_extract(value, '$.stats.damageDone')) dmg, 414 | sum(json_extract(value, '$.stats.deaths')) deaths, 415 | json_extract(value, '$.stats.teamPlacement') placement, 416 | json_extract(value, '$.stats.numberOfTeams') numberOfTeams 417 | from cte_exploded group by game_id order by date_key 418 | ), cte_only_full_teams AS ( 419 | select * from cte_summarized where 420 | (category='wz_solo' AND numPlayers = 1) OR 421 | (category='wz_duos' AND numPlayers = 2) OR 422 | (category='wz_trios' AND numPlayers = 3) OR 423 | (category='wz_quads' AND numPlayers = 4) OR 424 | 0 425 | ), cte_team_breakdowns AS ( 426 | select 427 | category, 428 | player_ids, 429 | numPlayers, 430 | count(1) numGames, 431 | sum( 432 | case 433 | when placement=1 then 1 434 | else 0 435 | end 436 | ) numWins, 437 | sum( 438 | case 439 | when placement=numberOfTeams then 1 440 | else 0 441 | end 442 | ) numLastPlaces, 443 | round(avg(kills), 2) avgKills, 444 | round(avg(dmg), 2) avgDmg, 445 | round(avg(deaths), 2) avgDeaths, 446 | round(avg(placement), 2) avgPlacement, 447 | max(kills) maxKills, 448 | max(dmg) maxDmg, 449 | max(deaths) maxDeaths 450 | from cte_only_full_teams 451 | group by category, player_ids 452 | ) 453 | 454 | select 455 | category, player_ids, numGames, numWins, numLastPlaces, avgKills, avgDmg, avgDeaths, avgPlacement, maxKills, maxDmg, maxDeaths, 456 | json_object( 457 | 'player_ids', player_ids, 458 | 'numGames', numGames, 459 | 'numWins', numWins, 460 | 'numLastPlaces', numLastPlaces, 461 | 'avgKills', avgKills, 462 | 'avgDmg', avgDmg, 463 | 'avgDeaths', avgDeaths, 464 | 'avgPlacement', avgPlacement, 465 | 'maxKills', maxKills, 466 | 'maxDmg', maxDmg, 467 | 'maxDeaths', maxDeaths 468 | ) jsonStats 469 | FROM cte_team_breakdowns where numGames > 1 470 | ; 471 | 472 | DROP VIEW IF EXISTS vw_player_stats_by_day_wz; 473 | CREATE VIEW vw_player_stats_by_day_wz AS 474 | SELECT 475 | date(date_key) 'date_key', 476 | player_id, 477 | count(1) 'matchesPlayed', 478 | sum(kills) 'kills', 479 | sum(deaths) 'deaths', 480 | sum(gulagKills) 'gulagKills', 481 | sum(gulagDeaths) 'gulagDeaths', 482 | sum(headshots) 'headshots', 483 | sum(damageDone) 'damageDone', 484 | sum(distanceTraveled) 'distanceTraveled', 485 | avg(kdRatio) 'kdRatio', 486 | avg(scorePerMinute) 'scorePerMinute', 487 | sum( 488 | case 489 | when kills >= (select int_value from vw_settings where id='monsters') then 1 490 | else 0 491 | end 492 | ) 'monsters', 493 | sum( 494 | case 495 | when kills = 0 then 1 496 | else 0 497 | end 498 | ) 'gooseeggs' 499 | FROM 500 | vw_stats_wz 501 | GROUP BY 502 | player_id, date(date_key) 503 | ORDER BY 504 | date_key 505 | ; 506 | 507 | -- NOTE(jpr): this isnt really needed but it helps to keep the logic in one place alongside the 'stats_by_day' version 508 | DROP VIEW IF EXISTS vw_player_stats_by_game_wz; 509 | CREATE VIEW vw_player_stats_by_game_wz AS 510 | SELECT 511 | date_key 'date_key', 512 | player_id, 513 | 1 'matchesPlayed', 514 | ifnull(vgm.display_name, 'Unknown <' || game_mode_sub || '>') 'mode', 515 | numberOfPlayers, 516 | numberOfTeams, 517 | teamPlacement, 518 | kills, 519 | deaths, 520 | gulagKills, 521 | gulagDeaths, 522 | headshots, 523 | damageDone, 524 | distanceTraveled, 525 | kdRatio, 526 | scorePerMinute, 527 | case 528 | WHEN kills >= (select int_value from vw_settings where id='monsters') then 1 529 | ELSE 0 530 | END 'monsters', 531 | case 532 | WHEN kills = 0 then 1 533 | ELSE 0 534 | END 'gooseeggs' 535 | FROM 536 | vw_stats_wz 537 | LEFT JOIN 538 | vw_game_modes vgm ON vgm.id=game_mode_sub 539 | ORDER BY 540 | date_key 541 | ; 542 | -- migrations 543 | EOF 544 | } 545 | 546 | seed_data() { 547 | local -r players_values_sql=$( cat ../config/players.json | jq -r ". | map({name: .name, unoId: (.accounts[].unoId)}) | unique | map( \"('\" + (.name | ascii_downcase) + \"', '\" + .unoId + \"')\") | join(\", \")" ) 548 | local -r players=$( cat ../config/players.json | jq -r "[.[]] | map( \"'\" + (.name | ascii_downcase) + \"'\") | join(\", \")" ) 549 | local -r core_players=$( cat ../config/players.json | jq -r "[.[] | select(.isCore)] | map( \"'\" + (.name | ascii_downcase) + \"'\") | join(\", \")" ) 550 | 551 | sqlite3 "${dbfile}" <<-EOF 552 | INSERT OR IGNORE INTO players(player_id, player_uno_id) VALUES 553 | ${players_values_sql}; 554 | 555 | UPDATE players SET 556 | is_core = 557 | CASE 558 | WHEN player_id IN (${core_players}) THEN 1 559 | ELSE 0 560 | END; 561 | 562 | DELETE FROM players WHERE player_id NOT IN (${players}); 563 | EOF 564 | } 565 | 566 | get_player_ids() { 567 | sqlite3 "${dbfile}" <<-EOF 568 | SELECT player_id FROM players ORDER BY player_id; 569 | 570 | EOF 571 | } 572 | 573 | get_player_uno_ids() { 574 | sqlite3 "${dbfile}" <<-EOF 575 | SELECT player_uno_id FROM players ORDER BY player_id; 576 | 577 | EOF 578 | } 579 | 580 | get_unwritten() { 581 | local -r source="${1}" 582 | local -r files=$( ls "${source}"/ | grep -Eo 'match_[0-9]+_[0-9]+' | sed 's/match_//g' | awk "{ print \" ('\" \$0 \"'),\"; }" ) 583 | 584 | sqlite3 "${dbfile}" <<-EOF 585 | WITH cte_lookup(id) AS ( 586 | VALUES 587 | ${files} 588 | ('xnullx') 589 | ) 590 | SELECT id FROM cte_lookup WHERE id NOT IN ( 591 | SELECT game_id || '_' || player_uno_id FROM raw_games 592 | UNION SELECT 'xnullx' 593 | ); 594 | 595 | EOF 596 | } 597 | 598 | initialize_db() { 599 | mkdir -p "${outdir}" 600 | 601 | create_tables 602 | seed_data 603 | } 604 | 605 | insert_stats_raw() { 606 | local -r player_key="${1}" 607 | local -r game_id="${2}" 608 | local -r data="$(echo "${3}" | sed "s/'/''/g")" 609 | 610 | ($debug_out && echo "[${game_id}] [${player_key}]") || true 611 | 612 | cat <<-EOF >> "${dbcommandsfile}" 613 | INSERT OR IGNORE INTO raw_games(game_id, player_uno_id, stats) VALUES 614 | ('${game_id}', '${player_key}', '${data}'); 615 | 616 | EOF 617 | } 618 | 619 | backfill_match_data() { 620 | cat <<-EOF >> "${dbcommandsfile}" 621 | INSERT OR IGNORE INTO wz_valid_games SELECT 622 | strftime('%Y-%m-%dT%H:%M:%SZ', json_extract(stats, '$.utcEndSeconds'), 'unixepoch') AS date_key, 623 | json_extract(stats, '$.gameType') AS game_mode, 624 | json_extract(stats, '$.mode') AS game_mode_sub, 625 | game_id, 626 | player_uno_id, 627 | ifnull(json_extract(stats, '$.playerCount'), -1) AS numberOfPlayers, 628 | ifnull(json_extract(stats, '$.teamCount'), -1) AS numberOfTeams, 629 | 630 | ifnull(json_extract(stats, '$.playerStats.score'), 0) AS score, 631 | ifnull(json_extract(stats, '$.playerStats.scorePerMinute'), 0) AS scorePerMinute, 632 | ifnull(json_extract(stats, '$.playerStats.kills'), 0) AS kills, 633 | ifnull(json_extract(stats, '$.playerStats.deaths'), 0) AS deaths, 634 | ifnull(json_extract(stats, '$.playerStats.damageDone'), 0) AS damageDone, 635 | ifnull(json_extract(stats, '$.playerStats.damageTaken'), 0) AS damageTaken, 636 | CASE 637 | -- NOTE(jpr): stimulus modes report each buyback as a gulagDeath 638 | WHEN json_extract(stats, '$.mode') IN (SELECT id FROM vw_game_modes WHERE is_stimulus=true) THEN 0 639 | WHEN ifnull(json_extract(stats, '$.playerStats.gulagKills'), 0) >= 1 THEN 1 640 | ELSE 0 641 | END AS gulagKills, 642 | CASE 643 | -- NOTE(jpr): stimulus modes report each buyback as a gulagDeath 644 | WHEN json_extract(stats, '$.mode') IN (SELECT id FROM vw_game_modes WHERE is_stimulus=true) THEN 0 645 | -- NOTE(jpr): it appears gulagDeaths is reported incorrectly if you die multiple times in a match. gulagWins seems 646 | -- to be correct, so lets defer to that. 647 | WHEN ifnull(json_extract(stats, '$.playerStats.gulagKills'), 0) >= 1 THEN 0 648 | WHEN ifnull(json_extract(stats, '$.playerStats.gulagDeaths'), 0) >= 1 THEN 1 649 | ELSE 0 650 | END AS gulagDeaths, 651 | ifnull(json_extract(stats, '$.playerStats.teamPlacement'), -1) AS teamPlacement, 652 | ifnull(json_extract(stats, '$.playerStats.kdRatio'), 0) AS kdRatio, 653 | ifnull(json_extract(stats, '$.playerStats.distanceTraveled'), 0) AS distanceTraveled, 654 | ifnull(json_extract(stats, '$.playerStats.headshots'), 0) AS headshots, 655 | ifnull(json_extract(stats, '$.playerStats.objectiveBrCacheOpen'), 0) AS objectiveBrCacheOpen, 656 | ifnull(json_extract(stats, '$.playerStats.objectiveReviver'), 0) AS objectiveReviver, 657 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle1'), 0) + 658 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle2'), 0) + 659 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle3'), 0) + 660 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle4'), 0) + 661 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle5'), 0) + 662 | ifnull(json_extract(stats, '$.playerStats.objectiveBrDownEnemyCircle6'), 0) + 663 | 0 664 | AS objectiveBrDownAll, 665 | ifnull(json_extract(stats, '$.playerStats.objectiveDestroyedVehicleLight'), 0) + 666 | ifnull(json_extract(stats, '$.playerStats.objectiveDestroyedVehicleMedium'), 0) + 667 | ifnull(json_extract(stats, '$.playerStats.objectiveDestroyedVehicleHeavy'), 0) + 668 | 0 669 | AS objectiveDestroyedVehicleAll, 670 | json_extract(stats, '$.playerStats') AS stats 671 | FROM 672 | raw_games 673 | WHERE 674 | player_uno_id IN (SELECT player_uno_id FROM players) AND 675 | game_id || '_' || player_uno_id NOT IN (select game_id || '_' || player_uno_id FROM wz_valid_games) AND 676 | 677 | -- filter out buggy stats. seems to be a lot from early on in API, not so much lately 678 | NOT (json_extract(stats, '$.playerStats.damageDone') is null) AND 679 | NOT (json_extract(stats, '$.playerStats.damageTaken') is null) AND 680 | NOT (deaths = 0 AND damageTaken = 0) AND 681 | 682 | -- TODO(jpr): think about filtering out early leaves / DCs. this is mostly handled 683 | -- by the (deaths=0 and damageTaken=0) above, but there are still some obvious DCs 684 | -- which may or may not want to be included 685 | 686 | 1 687 | ; 688 | 689 | EOF 690 | } 691 | 692 | ### 693 | 694 | get_ts() { 695 | echo $( gdate +%s%N | cut -b1-13 ) 696 | } 697 | 698 | ingest_successes() { 699 | local -r source="${1}" 700 | local -n player_names=$2 701 | local -n player_uno_ids=$3 702 | 703 | [ -d "${source}" ] || die "no dir at [${source}]" 704 | 705 | local other=0 706 | local player= 707 | 708 | local unwritten=($( get_unwritten "${source}" )) 709 | echo "Writing [${#unwritten[@]}] new results" 710 | 711 | for ((idx=0; idx<${#unwritten[@]}; ++idx)); do 712 | local rawname="${unwritten[idx]}" 713 | local match_id="$(echo "${rawname}" | awk -F'_' '{print $1;}')" 714 | local match_unoid="$(echo "${rawname}" | awk -F'_' '{print $2;}')" 715 | local file="${source}/match_${match_id}_${match_unoid}.json" 716 | local data=$(cat "${file}") 717 | 718 | insert_stats_raw "${match_unoid}" "${match_id}" "${data}" 719 | done 720 | } 721 | 722 | main() { 723 | initialize_db 724 | local -r __ids=($( get_player_ids )) 725 | local -r __uno_ids=($( get_player_uno_ids )) 726 | 727 | write_sql_start 728 | 729 | ingest_successes "${sourcedir}" __ids __uno_ids 730 | backfill_match_data 731 | 732 | write_sql_end 733 | commit_sql 734 | } 735 | 736 | main 737 | -------------------------------------------------------------------------------- /fetcher/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetcher", 3 | "version": "1.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@types/debug": { 8 | "version": "4.1.7", 9 | "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", 10 | "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", 11 | "requires": { 12 | "@types/ms": "*" 13 | } 14 | }, 15 | "@types/ms": { 16 | "version": "0.7.31", 17 | "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", 18 | "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" 19 | }, 20 | "@types/node": { 21 | "version": "14.0.13", 22 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.13.tgz", 23 | "integrity": "sha512-rouEWBImiRaSJsVA+ITTFM6ZxibuAlTuNOCyxVbwreu6k6+ujs7DfnU9o+PShFhET78pMBl3eH+AGSI5eOTkPA==" 24 | }, 25 | "@types/puppeteer": { 26 | "version": "5.4.3", 27 | "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-5.4.3.tgz", 28 | "integrity": "sha512-3nE8YgR9DIsgttLW+eJf6mnXxq8Ge+27m5SU3knWmrlfl6+KOG0Bf9f7Ua7K+C4BnaTMAh3/UpySqdAYvrsvjg==", 29 | "requires": { 30 | "@types/node": "*" 31 | } 32 | }, 33 | "@types/yauzl": { 34 | "version": "2.9.2", 35 | "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", 36 | "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==", 37 | "optional": true, 38 | "requires": { 39 | "@types/node": "*" 40 | } 41 | }, 42 | "agent-base": { 43 | "version": "6.0.2", 44 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", 45 | "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", 46 | "requires": { 47 | "debug": "4" 48 | } 49 | }, 50 | "arr-union": { 51 | "version": "3.1.0", 52 | "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", 53 | "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=" 54 | }, 55 | "at-least-node": { 56 | "version": "1.0.0", 57 | "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", 58 | "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" 59 | }, 60 | "axios": { 61 | "version": "0.21.4", 62 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", 63 | "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", 64 | "requires": { 65 | "follow-redirects": "^1.14.0" 66 | } 67 | }, 68 | "axios-rate-limit": { 69 | "version": "1.3.0", 70 | "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.3.0.tgz", 71 | "integrity": "sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==" 72 | }, 73 | "balanced-match": { 74 | "version": "1.0.2", 75 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 76 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 77 | }, 78 | "base64-js": { 79 | "version": "1.5.1", 80 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 81 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 82 | }, 83 | "bl": { 84 | "version": "4.1.0", 85 | "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 86 | "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 87 | "requires": { 88 | "buffer": "^5.5.0", 89 | "inherits": "^2.0.4", 90 | "readable-stream": "^3.4.0" 91 | } 92 | }, 93 | "brace-expansion": { 94 | "version": "1.1.11", 95 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 96 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 97 | "requires": { 98 | "balanced-match": "^1.0.0", 99 | "concat-map": "0.0.1" 100 | } 101 | }, 102 | "buffer": { 103 | "version": "5.7.1", 104 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 105 | "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 106 | "requires": { 107 | "base64-js": "^1.3.1", 108 | "ieee754": "^1.1.13" 109 | } 110 | }, 111 | "buffer-crc32": { 112 | "version": "0.2.13", 113 | "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", 114 | "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" 115 | }, 116 | "call-of-duty-api": { 117 | "version": "2.1.2", 118 | "resolved": "https://registry.npmjs.org/call-of-duty-api/-/call-of-duty-api-2.1.2.tgz", 119 | "integrity": "sha512-3eYbkZcZAK5OUTQKmoShKMXq/Wd7q1eSAQJfX0kttNGOt6g7HI++YvwocebBFN7V2tAuVblQrb5EtNz40HEFdg==", 120 | "requires": { 121 | "axios": "^0.21.1", 122 | "axios-rate-limit": "^1.3.0", 123 | "puppeteer": "^10.1.0", 124 | "puppeteer-extra": "^3.1.18", 125 | "puppeteer-extra-plugin-recaptcha": "^3.4.0", 126 | "puppeteer-extra-plugin-stealth": "^2.7.8", 127 | "uniqid": "^5.3.0" 128 | } 129 | }, 130 | "chownr": { 131 | "version": "1.1.4", 132 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 133 | "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" 134 | }, 135 | "clone-deep": { 136 | "version": "0.2.4", 137 | "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", 138 | "integrity": "sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=", 139 | "requires": { 140 | "for-own": "^0.1.3", 141 | "is-plain-object": "^2.0.1", 142 | "kind-of": "^3.0.2", 143 | "lazy-cache": "^1.0.3", 144 | "shallow-clone": "^0.1.2" 145 | } 146 | }, 147 | "concat-map": { 148 | "version": "0.0.1", 149 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 150 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" 151 | }, 152 | "debug": { 153 | "version": "4.3.1", 154 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", 155 | "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", 156 | "requires": { 157 | "ms": "2.1.2" 158 | } 159 | }, 160 | "deepmerge": { 161 | "version": "4.2.2", 162 | "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", 163 | "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" 164 | }, 165 | "devtools-protocol": { 166 | "version": "0.0.901419", 167 | "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.901419.tgz", 168 | "integrity": "sha512-4INMPwNm9XRpBukhNbF7OB6fNTTCaI8pzy/fXg0xQzAy5h3zL1P8xT3QazgKqBrb/hAYwIBizqDBZ7GtJE74QQ==" 169 | }, 170 | "end-of-stream": { 171 | "version": "1.4.4", 172 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 173 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 174 | "requires": { 175 | "once": "^1.4.0" 176 | } 177 | }, 178 | "extract-zip": { 179 | "version": "2.0.1", 180 | "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", 181 | "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", 182 | "requires": { 183 | "@types/yauzl": "^2.9.1", 184 | "debug": "^4.1.1", 185 | "get-stream": "^5.1.0", 186 | "yauzl": "^2.10.0" 187 | } 188 | }, 189 | "fd-slicer": { 190 | "version": "1.1.0", 191 | "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", 192 | "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", 193 | "requires": { 194 | "pend": "~1.2.0" 195 | } 196 | }, 197 | "find-up": { 198 | "version": "4.1.0", 199 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 200 | "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 201 | "requires": { 202 | "locate-path": "^5.0.0", 203 | "path-exists": "^4.0.0" 204 | } 205 | }, 206 | "follow-redirects": { 207 | "version": "1.14.3", 208 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.3.tgz", 209 | "integrity": "sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==" 210 | }, 211 | "for-in": { 212 | "version": "1.0.2", 213 | "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", 214 | "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" 215 | }, 216 | "for-own": { 217 | "version": "0.1.5", 218 | "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", 219 | "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", 220 | "requires": { 221 | "for-in": "^1.0.1" 222 | } 223 | }, 224 | "fs-constants": { 225 | "version": "1.0.0", 226 | "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 227 | "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" 228 | }, 229 | "fs-extra": { 230 | "version": "9.1.0", 231 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", 232 | "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", 233 | "requires": { 234 | "at-least-node": "^1.0.0", 235 | "graceful-fs": "^4.2.0", 236 | "jsonfile": "^6.0.1", 237 | "universalify": "^2.0.0" 238 | } 239 | }, 240 | "fs.realpath": { 241 | "version": "1.0.0", 242 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 243 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" 244 | }, 245 | "get-stream": { 246 | "version": "5.2.0", 247 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", 248 | "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", 249 | "requires": { 250 | "pump": "^3.0.0" 251 | } 252 | }, 253 | "glob": { 254 | "version": "7.1.7", 255 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", 256 | "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", 257 | "requires": { 258 | "fs.realpath": "^1.0.0", 259 | "inflight": "^1.0.4", 260 | "inherits": "2", 261 | "minimatch": "^3.0.4", 262 | "once": "^1.3.0", 263 | "path-is-absolute": "^1.0.0" 264 | } 265 | }, 266 | "graceful-fs": { 267 | "version": "4.2.8", 268 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", 269 | "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==" 270 | }, 271 | "https-proxy-agent": { 272 | "version": "5.0.0", 273 | "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", 274 | "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", 275 | "requires": { 276 | "agent-base": "6", 277 | "debug": "4" 278 | } 279 | }, 280 | "ieee754": { 281 | "version": "1.2.1", 282 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 283 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" 284 | }, 285 | "inflight": { 286 | "version": "1.0.6", 287 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 288 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 289 | "requires": { 290 | "once": "^1.3.0", 291 | "wrappy": "1" 292 | } 293 | }, 294 | "inherits": { 295 | "version": "2.0.4", 296 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 297 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 298 | }, 299 | "is-buffer": { 300 | "version": "1.1.6", 301 | "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", 302 | "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" 303 | }, 304 | "is-extendable": { 305 | "version": "0.1.1", 306 | "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", 307 | "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" 308 | }, 309 | "is-plain-object": { 310 | "version": "2.0.4", 311 | "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", 312 | "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", 313 | "requires": { 314 | "isobject": "^3.0.1" 315 | } 316 | }, 317 | "isobject": { 318 | "version": "3.0.1", 319 | "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", 320 | "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" 321 | }, 322 | "jsonfile": { 323 | "version": "6.1.0", 324 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", 325 | "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", 326 | "requires": { 327 | "graceful-fs": "^4.1.6", 328 | "universalify": "^2.0.0" 329 | } 330 | }, 331 | "kind-of": { 332 | "version": "3.2.2", 333 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", 334 | "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", 335 | "requires": { 336 | "is-buffer": "^1.1.5" 337 | } 338 | }, 339 | "lazy-cache": { 340 | "version": "1.0.4", 341 | "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", 342 | "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=" 343 | }, 344 | "locate-path": { 345 | "version": "5.0.0", 346 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 347 | "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 348 | "requires": { 349 | "p-locate": "^4.1.0" 350 | } 351 | }, 352 | "merge-deep": { 353 | "version": "3.0.3", 354 | "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", 355 | "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", 356 | "requires": { 357 | "arr-union": "^3.1.0", 358 | "clone-deep": "^0.2.4", 359 | "kind-of": "^3.0.2" 360 | } 361 | }, 362 | "minimatch": { 363 | "version": "3.0.4", 364 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", 365 | "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", 366 | "requires": { 367 | "brace-expansion": "^1.1.7" 368 | } 369 | }, 370 | "minimist": { 371 | "version": "1.2.5", 372 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", 373 | "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" 374 | }, 375 | "mixin-object": { 376 | "version": "2.0.1", 377 | "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", 378 | "integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=", 379 | "requires": { 380 | "for-in": "^0.1.3", 381 | "is-extendable": "^0.1.1" 382 | }, 383 | "dependencies": { 384 | "for-in": { 385 | "version": "0.1.8", 386 | "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", 387 | "integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=" 388 | } 389 | } 390 | }, 391 | "mkdirp": { 392 | "version": "0.5.5", 393 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", 394 | "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", 395 | "requires": { 396 | "minimist": "^1.2.5" 397 | } 398 | }, 399 | "ms": { 400 | "version": "2.1.2", 401 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 402 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 403 | }, 404 | "node-fetch": { 405 | "version": "2.6.1", 406 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", 407 | "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" 408 | }, 409 | "once": { 410 | "version": "1.4.0", 411 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 412 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 413 | "requires": { 414 | "wrappy": "1" 415 | } 416 | }, 417 | "p-limit": { 418 | "version": "2.3.0", 419 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 420 | "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 421 | "requires": { 422 | "p-try": "^2.0.0" 423 | } 424 | }, 425 | "p-locate": { 426 | "version": "4.1.0", 427 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 428 | "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 429 | "requires": { 430 | "p-limit": "^2.2.0" 431 | } 432 | }, 433 | "p-try": { 434 | "version": "2.2.0", 435 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 436 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" 437 | }, 438 | "path-exists": { 439 | "version": "4.0.0", 440 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 441 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" 442 | }, 443 | "path-is-absolute": { 444 | "version": "1.0.1", 445 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 446 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" 447 | }, 448 | "pend": { 449 | "version": "1.2.0", 450 | "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", 451 | "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" 452 | }, 453 | "pkg-dir": { 454 | "version": "4.2.0", 455 | "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", 456 | "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", 457 | "requires": { 458 | "find-up": "^4.0.0" 459 | } 460 | }, 461 | "progress": { 462 | "version": "2.0.1", 463 | "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.1.tgz", 464 | "integrity": "sha512-OE+a6vzqazc+K6LxJrX5UPyKFvGnL5CYmq2jFGNIBWHpc4QyE49/YOumcrpQFJpfejmvRtbJzgO1zPmMCqlbBg==" 465 | }, 466 | "proxy-from-env": { 467 | "version": "1.1.0", 468 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 469 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 470 | }, 471 | "pump": { 472 | "version": "3.0.0", 473 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 474 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 475 | "requires": { 476 | "end-of-stream": "^1.1.0", 477 | "once": "^1.3.1" 478 | } 479 | }, 480 | "puppeteer": { 481 | "version": "10.2.0", 482 | "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-10.2.0.tgz", 483 | "integrity": "sha512-OR2CCHRashF+f30+LBOtAjK6sNtz2HEyTr5FqAvhf8lR/qB3uBRoIZOwQKgwoyZnMBsxX7ZdazlyBgGjpnkiMw==", 484 | "requires": { 485 | "debug": "4.3.1", 486 | "devtools-protocol": "0.0.901419", 487 | "extract-zip": "2.0.1", 488 | "https-proxy-agent": "5.0.0", 489 | "node-fetch": "2.6.1", 490 | "pkg-dir": "4.2.0", 491 | "progress": "2.0.1", 492 | "proxy-from-env": "1.1.0", 493 | "rimraf": "3.0.2", 494 | "tar-fs": "2.0.0", 495 | "unbzip2-stream": "1.3.3", 496 | "ws": "7.4.6" 497 | } 498 | }, 499 | "puppeteer-extra": { 500 | "version": "3.1.18", 501 | "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.1.18.tgz", 502 | "integrity": "sha512-mGQyAnxaGcZomx7NVC4wgAkZl0MLTdE/GIfwRSbLJ9L4yIxPg9uEA3yiLBe+x09tjhTGEtv8KDef8Bl53RXgiA==", 503 | "requires": { 504 | "@types/debug": "^4.1.0", 505 | "@types/puppeteer": "5.4.3", 506 | "debug": "^4.1.1", 507 | "deepmerge": "^4.2.2" 508 | } 509 | }, 510 | "puppeteer-extra-plugin": { 511 | "version": "3.1.9", 512 | "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.1.9.tgz", 513 | "integrity": "sha512-LYKj+3wGsnBzwEIpTertyOkzjcTHJn96FDSFXQ4Oo38CFFRw1qRBAJPPtAaMVjuVDqATeNd/RpP4n5jbxeX90g==", 514 | "requires": { 515 | "@types/debug": "^4.1.0", 516 | "debug": "^4.1.1", 517 | "merge-deep": "^3.0.1" 518 | } 519 | }, 520 | "puppeteer-extra-plugin-recaptcha": { 521 | "version": "3.4.0", 522 | "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-recaptcha/-/puppeteer-extra-plugin-recaptcha-3.4.0.tgz", 523 | "integrity": "sha512-JgQfIgkZNt5GKekQBzYLCxy0rUGYunscJ5paqI7tYY9xSNDI3AL5zKuKWaSENG2g54TNgCt85+wUl+fZZWHytw==", 524 | "requires": { 525 | "debug": "^4.1.1", 526 | "merge-deep": "^3.0.2", 527 | "puppeteer-extra-plugin": "^3.1.9" 528 | } 529 | }, 530 | "puppeteer-extra-plugin-stealth": { 531 | "version": "2.7.8", 532 | "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.7.8.tgz", 533 | "integrity": "sha512-Zhm/WY/BAk9VGdZR5OVpiwfGn2NoAzEb0hdu3/PGRryfenn8Dtoai8aUa8GzFPExWL+yGPsztswupH+3TV3M2A==", 534 | "requires": { 535 | "debug": "^4.1.1", 536 | "puppeteer-extra-plugin": "^3.1.9", 537 | "puppeteer-extra-plugin-user-preferences": "^2.2.12" 538 | } 539 | }, 540 | "puppeteer-extra-plugin-user-data-dir": { 541 | "version": "2.2.12", 542 | "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.2.12.tgz", 543 | "integrity": "sha512-+RTNevJLswyh/RfQ+c46otTtE9eABKv8KkLGHcz1jpnVH/iX2wHu7VkXA5Y9pKBaLYDt83BON8wCowy5Eh/KWA==", 544 | "requires": { 545 | "debug": "^4.1.1", 546 | "fs-extra": "^9.1.0", 547 | "puppeteer-extra-plugin": "^3.1.9" 548 | } 549 | }, 550 | "puppeteer-extra-plugin-user-preferences": { 551 | "version": "2.2.12", 552 | "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.2.12.tgz", 553 | "integrity": "sha512-y4YOykpYHq0t8qnRyuSL6sIDIRKgdlKkIIFdRbfZx3cg7nxeVLkHgEz3v/Ob0U/lvzlSrr4xvHhG+KcVWH7BXA==", 554 | "requires": { 555 | "debug": "^4.1.1", 556 | "deepmerge": "^4.2.2", 557 | "puppeteer-extra-plugin": "^3.1.9", 558 | "puppeteer-extra-plugin-user-data-dir": "^2.2.12" 559 | } 560 | }, 561 | "readable-stream": { 562 | "version": "3.6.0", 563 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", 564 | "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", 565 | "requires": { 566 | "inherits": "^2.0.3", 567 | "string_decoder": "^1.1.1", 568 | "util-deprecate": "^1.0.1" 569 | } 570 | }, 571 | "rimraf": { 572 | "version": "3.0.2", 573 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 574 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 575 | "requires": { 576 | "glob": "^7.1.3" 577 | } 578 | }, 579 | "safe-buffer": { 580 | "version": "5.2.1", 581 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 582 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 583 | }, 584 | "shallow-clone": { 585 | "version": "0.1.2", 586 | "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", 587 | "integrity": "sha1-WQnodLp3EG1zrEFM/sH/yofZcGA=", 588 | "requires": { 589 | "is-extendable": "^0.1.1", 590 | "kind-of": "^2.0.1", 591 | "lazy-cache": "^0.2.3", 592 | "mixin-object": "^2.0.1" 593 | }, 594 | "dependencies": { 595 | "kind-of": { 596 | "version": "2.0.1", 597 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", 598 | "integrity": "sha1-AY7HpM5+OobLkUG+UZ0kyPqpgbU=", 599 | "requires": { 600 | "is-buffer": "^1.0.2" 601 | } 602 | }, 603 | "lazy-cache": { 604 | "version": "0.2.7", 605 | "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", 606 | "integrity": "sha1-f+3fLctu23fRHvHRF6tf/fCrG2U=" 607 | } 608 | } 609 | }, 610 | "string_decoder": { 611 | "version": "1.3.0", 612 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 613 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 614 | "requires": { 615 | "safe-buffer": "~5.2.0" 616 | } 617 | }, 618 | "tar-fs": { 619 | "version": "2.0.0", 620 | "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.0.tgz", 621 | "integrity": "sha512-vaY0obB6Om/fso8a8vakQBzwholQ7v5+uy+tF3Ozvxv1KNezmVQAiWtcNmMHFSFPqL3dJA8ha6gdtFbfX9mcxA==", 622 | "requires": { 623 | "chownr": "^1.1.1", 624 | "mkdirp": "^0.5.1", 625 | "pump": "^3.0.0", 626 | "tar-stream": "^2.0.0" 627 | } 628 | }, 629 | "tar-stream": { 630 | "version": "2.2.0", 631 | "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 632 | "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 633 | "requires": { 634 | "bl": "^4.0.3", 635 | "end-of-stream": "^1.4.1", 636 | "fs-constants": "^1.0.0", 637 | "inherits": "^2.0.3", 638 | "readable-stream": "^3.1.1" 639 | } 640 | }, 641 | "through": { 642 | "version": "2.3.8", 643 | "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", 644 | "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" 645 | }, 646 | "typescript": { 647 | "version": "3.9.2", 648 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.2.tgz", 649 | "integrity": "sha512-q2ktq4n/uLuNNShyayit+DTobV2ApPEo/6so68JaD5ojvc/6GClBipedB9zNWYxRSAlZXAe405Rlijzl6qDiSw==", 650 | "dev": true 651 | }, 652 | "unbzip2-stream": { 653 | "version": "1.3.3", 654 | "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz", 655 | "integrity": "sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg==", 656 | "requires": { 657 | "buffer": "^5.2.1", 658 | "through": "^2.3.8" 659 | } 660 | }, 661 | "uniqid": { 662 | "version": "5.4.0", 663 | "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-5.4.0.tgz", 664 | "integrity": "sha512-38JRbJ4Fj94VmnC7G/J/5n5SC7Ab46OM5iNtSstB/ko3l1b5g7ALt4qzHFgGciFkyiRNtDXtLNb+VsxtMSE77A==" 665 | }, 666 | "universalify": { 667 | "version": "2.0.0", 668 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", 669 | "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" 670 | }, 671 | "util-deprecate": { 672 | "version": "1.0.2", 673 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 674 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 675 | }, 676 | "wrappy": { 677 | "version": "1.0.2", 678 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 679 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" 680 | }, 681 | "ws": { 682 | "version": "7.4.6", 683 | "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", 684 | "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" 685 | }, 686 | "yauzl": { 687 | "version": "2.10.0", 688 | "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", 689 | "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", 690 | "requires": { 691 | "buffer-crc32": "~0.2.3", 692 | "fd-slicer": "~1.1.0" 693 | } 694 | } 695 | } 696 | } 697 | -------------------------------------------------------------------------------- /frontend/generate_lookup_data.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly sourcedir="${COD_DATADIR}/parser/output" 6 | readonly outdir_staging=$(mktemp -d) 7 | readonly outdir_staging_data="${outdir_staging}/data/output" 8 | readonly outdir_dest="${COD_DATADIR}/frontend/output" 9 | 10 | readonly dbfile="${sourcedir}/data.sqlite" 11 | 12 | readonly debug_out=false 13 | 14 | die() { 15 | local -r msg="${1}" 16 | 17 | echo "ERROR: ${msg}" 18 | exit 1 19 | } 20 | 21 | if [ ! -f "${dbfile}" ]; then 22 | die "no db found at [${dbfile}]" 23 | fi 24 | 25 | if [ ! -d "${outdir_staging}" ]; then 26 | die "no directory found at [${outdir_staging}]" 27 | fi 28 | 29 | mkdir -p "${outdir_dest}" 30 | mkdir -p "${outdir_staging_data}" 31 | 32 | ### SQL 33 | 34 | get_player_ids() { 35 | sqlite3 "${dbfile}" <<-EOF 36 | SELECT player_id FROM players ORDER BY player_id; 37 | EOF 38 | } 39 | 40 | get_ts() { 41 | echo $(date +%s%N | cut -b1-13) 42 | } 43 | 44 | report_file_written() { 45 | local -r outpath="${1}" 46 | local -r start_ts="${2}" 47 | local -r end_ts="${3}" 48 | 49 | echo "wrote [$(basename "${outpath}")] in [$((end_ts - start_ts))ms]" 50 | } 51 | 52 | ### 53 | 54 | write_meta() { 55 | local start=$(get_ts) 56 | cp ../config/players.json "${outdir_staging_data}/players.json" 57 | local end=$(get_ts) 58 | report_file_written "${outdir_staging_data}/players.json" "${start}" "${end}" 59 | 60 | local start=$(get_ts) 61 | local -r ts=$(get_ts) 62 | echo "{\"updatedAt\": ${ts}}" >"${outdir_staging_data}/meta.json" 63 | local end=$(get_ts) 64 | report_file_written "${outdir_staging_data}/meta.json" "${start}" "${end}" 65 | 66 | local start=$(get_ts) 67 | local -r data_seasons=$( 68 | sqlite3 "${dbfile}" <<-EOF 69 | WITH cte_seasons AS ( 70 | SELECT row_number() OVER (ORDER BY start DESC) rn, * FROM vw_seasons ORDER BY sort_order 71 | ) 72 | 73 | SELECT 74 | json_object( 75 | 'current', (SELECT id FROM cte_seasons WHERE rn=1), 76 | 'seasons', json_group_array( 77 | json_object( 78 | 'id', id, 79 | 'desc', desc, 80 | 'start', start, 81 | 'end', end 82 | ) 83 | ) 84 | ) meta 85 | FROM cte_seasons; 86 | EOF 87 | ) 88 | echo "${data_seasons}" >"${outdir_staging_data}/seasons.json" 89 | local end=$(get_ts) 90 | report_file_written "${outdir_staging_data}/seasons.json" "${start}" "${end}" 91 | } 92 | 93 | write_leaderboards() { 94 | local -r num_results=10 95 | 96 | local start=$(get_ts) 97 | local data_leaderboard=$( 98 | sqlite3 "${dbfile}" <<-EOF 99 | WITH 100 | 101 | cte_mostkills as ( 102 | SELECT json_object( 103 | 'date_key', date_key, 104 | 'game_mode_sub', game_mode_sub, 105 | 'game_id', game_id, 106 | 'player_id', player_id, 107 | 'value', kills 108 | ) AS meta 109 | FROM 110 | vw_stats_wz 111 | WHERE 112 | player_id IN (SELECT * FROM vw_core_players) AND 113 | 1 114 | ORDER BY 115 | kills DESC 116 | LIMIT ${num_results} 117 | ), 118 | 119 | cte_mostdeaths as ( 120 | SELECT json_object( 121 | 'date_key', date_key, 122 | 'game_mode_sub', game_mode_sub, 123 | 'game_id', game_id, 124 | 'player_id', player_id, 125 | 'value', deaths 126 | ) AS meta 127 | FROM 128 | vw_stats_wz 129 | WHERE 130 | player_id IN (SELECT * FROM vw_core_players) AND 131 | 1 132 | ORDER BY 133 | deaths DESC 134 | LIMIT ${num_results} 135 | ), 136 | 137 | cte_highestkd as ( 138 | SELECT json_object( 139 | 'date_key', date_key, 140 | 'game_mode_sub', game_mode_sub, 141 | 'game_id', game_id, 142 | 'player_id', player_id, 143 | 'value', kdRatio 144 | ) AS meta 145 | FROM 146 | vw_stats_wz 147 | WHERE 148 | player_id IN (SELECT * FROM vw_core_players) AND 149 | 1 150 | ORDER BY 151 | kdRatio DESC 152 | LIMIT ${num_results} 153 | ), 154 | 155 | cte_damagedone as ( 156 | SELECT json_object( 157 | 'date_key', date_key, 158 | 'game_mode_sub', game_mode_sub, 159 | 'game_id', game_id, 160 | 'player_id', player_id, 161 | 'value', damageDone 162 | ) AS meta 163 | FROM 164 | vw_stats_wz 165 | WHERE 166 | player_id IN (SELECT * FROM vw_core_players) AND 167 | 1 168 | ORDER BY 169 | damageDone DESC 170 | LIMIT ${num_results} 171 | ), 172 | 173 | cte_damagetaken as ( 174 | SELECT json_object( 175 | 'date_key', date_key, 176 | 'game_mode_sub', game_mode_sub, 177 | 'game_id', game_id, 178 | 'player_id', player_id, 179 | 'value', damageTaken 180 | ) AS meta 181 | FROM 182 | vw_stats_wz 183 | WHERE 184 | player_id IN (SELECT * FROM vw_core_players) AND 185 | 1 186 | ORDER BY 187 | damageTaken DESC 188 | LIMIT ${num_results} 189 | ), 190 | 191 | cte_highestscore as ( 192 | SELECT json_object( 193 | 'date_key', date_key, 194 | 'game_mode_sub', game_mode_sub, 195 | 'game_id', game_id, 196 | 'player_id', player_id, 197 | 'value', score 198 | ) AS meta 199 | FROM 200 | vw_stats_wz 201 | WHERE 202 | player_id IN (SELECT * FROM vw_core_players) AND 203 | 1 204 | ORDER BY 205 | score DESC 206 | LIMIT ${num_results} 207 | ), 208 | 209 | cte_mostdistance as ( 210 | SELECT json_object( 211 | 'date_key', date_key, 212 | 'game_mode_sub', game_mode_sub, 213 | 'game_id', game_id, 214 | 'player_id', player_id, 215 | 'value', cast((distanceTraveled / 1000) as int) || ' km' 216 | ) AS meta 217 | FROM 218 | vw_stats_wz 219 | WHERE 220 | player_id IN (SELECT * FROM vw_core_players) AND 221 | 1 222 | ORDER BY 223 | distanceTraveled DESC 224 | LIMIT ${num_results} 225 | ), 226 | 227 | cte_mostheadshots as ( 228 | SELECT json_object( 229 | 'date_key', date_key, 230 | 'game_mode_sub', game_mode_sub, 231 | 'game_id', game_id, 232 | 'player_id', player_id, 233 | 'value', headshots 234 | ) AS meta 235 | FROM 236 | vw_stats_wz 237 | WHERE 238 | player_id IN (SELECT * FROM vw_core_players) AND 239 | 1 240 | ORDER BY 241 | headshots DESC 242 | LIMIT ${num_results} 243 | ), 244 | 245 | cte_mostlootboxes as ( 246 | SELECT json_object( 247 | 'date_key', date_key, 248 | 'game_mode_sub', game_mode_sub, 249 | 'game_id', game_id, 250 | 'player_id', player_id, 251 | 'value', objectiveBrCacheOpen 252 | ) AS meta 253 | FROM 254 | vw_stats_wz 255 | WHERE 256 | player_id IN (SELECT * FROM vw_core_players) AND 257 | 1 258 | ORDER BY 259 | objectiveBrCacheOpen DESC 260 | LIMIT ${num_results} 261 | ), 262 | 263 | cte_mostrevives as ( 264 | SELECT json_object( 265 | 'date_key', date_key, 266 | 'game_mode_sub', game_mode_sub, 267 | 'game_id', game_id, 268 | 'player_id', player_id, 269 | 'value', objectiveReviver 270 | ) AS meta 271 | FROM 272 | vw_stats_wz 273 | WHERE 274 | player_id IN (SELECT * FROM vw_core_players) AND 275 | 1 276 | ORDER BY 277 | objectiveReviver DESC 278 | LIMIT ${num_results} 279 | ), 280 | 281 | cte_mostdowns as ( 282 | SELECT json_object( 283 | 'date_key', date_key, 284 | 'game_mode_sub', game_mode_sub, 285 | 'game_id', game_id, 286 | 'player_id', player_id, 287 | 'value', objectiveBrDownAll 288 | ) AS meta 289 | FROM 290 | vw_stats_wz 291 | WHERE 292 | player_id IN (SELECT * FROM vw_core_players) AND 293 | 1 294 | ORDER BY 295 | objectiveBrDownAll DESC 296 | LIMIT ${num_results} 297 | ), 298 | 299 | cte_mostvehiclesdestroyed as ( 300 | SELECT json_object( 301 | 'date_key', date_key, 302 | 'game_mode_sub', game_mode_sub, 303 | 'game_id', game_id, 304 | 'player_id', player_id, 305 | 'value', objectiveDestroyedVehicleAll 306 | ) AS meta 307 | FROM 308 | vw_stats_wz 309 | WHERE 310 | player_id IN (SELECT * FROM vw_core_players) AND 311 | 1 312 | ORDER BY 313 | objectiveDestroyedVehicleAll DESC 314 | LIMIT ${num_results} 315 | ), 316 | 317 | cte_null as (SELECT 1) 318 | 319 | SELECT json_array( 320 | (select json_object('title', 'Kills', 321 | 'meta', json_group_array(json(meta))) FROM cte_mostkills), 322 | (select json_object('title', 'K/D', 323 | 'meta', json_group_array(json(meta))) FROM cte_highestkd), 324 | (select json_object('title', 'Damage done', 325 | 'meta', json_group_array(json(meta))) FROM cte_damagedone), 326 | (select json_object('title', 'Downs', 327 | 'meta', json_group_array(json(meta))) FROM cte_mostdowns), 328 | (select json_object('title', 'Revives', 329 | 'meta', json_group_array(json(meta))) FROM cte_mostrevives), 330 | (select json_object('title', 'Vehicles destroyed', 331 | 'meta', json_group_array(json(meta))) FROM cte_mostvehiclesdestroyed), 332 | (select json_object('title', 'Boxes looted', 333 | 'meta', json_group_array(json(meta))) FROM cte_mostlootboxes), 334 | (select json_object('title', 'Score', 335 | 'meta', json_group_array(json(meta))) FROM cte_highestscore), 336 | (select json_object('title', 'Headshots', 337 | 'meta', json_group_array(json(meta))) FROM cte_mostheadshots), 338 | (select json_object('title', 'Distance traveled', 339 | 'meta', json_group_array(json(meta))) FROM cte_mostdistance), 340 | (select json_object('title', 'Deaths', 341 | 'meta', json_group_array(json(meta))) FROM cte_mostdeaths), 342 | (select json_object('title', 'Damage taken', 343 | 'meta', json_group_array(json(meta))) FROM cte_damagetaken) 344 | ) as leaderboard; 345 | EOF 346 | ) 347 | 348 | local outpath="${outdir_staging_data}/leaderboard_bygame.json" 349 | echo "${data_leaderboard}" >"${outpath}" 350 | local end=$(get_ts) 351 | report_file_written "${outpath}" "${start}" "${end}" 352 | 353 | local start=$(get_ts) 354 | local data_leaderboard=$( 355 | sqlite3 "${dbfile}" <<-EOF 356 | WITH 357 | 358 | cte_gulag_by_kills AS ( 359 | SELECT 360 | ( 361 | -- https://dba.stackexchange.com/a/254178 362 | DENSE_RANK() OVER (PARTITION BY player_id ORDER BY date_key) - DENSE_RANK() OVER (PARTITION BY player_id, gulagKills ORDER BY date_key) 363 | ) AS gulag_group, 364 | gulagKills, gulagDeaths, date_key, game_id, player_id, stats 365 | FROM vw_stats_wz 366 | WHERE 367 | player_id IN (SELECT * from vw_core_players) AND 368 | (gulagKills=1 OR gulagDeaths=1) AND 369 | 1 370 | ), 371 | cte_gulag_by_deaths AS ( 372 | SELECT 373 | ( 374 | -- https://dba.stackexchange.com/a/254178 375 | DENSE_RANK() OVER (PARTITION BY player_id ORDER BY date_key) - DENSE_RANK() OVER (PARTITION BY player_id, gulagDeaths ORDER BY date_key) 376 | ) AS gulag_group, 377 | gulagKills, gulagDeaths, date_key, game_id, player_id, stats 378 | FROM vw_stats_wz 379 | WHERE 380 | player_id IN (SELECT * from vw_core_players) AND 381 | (gulagKills=1 OR gulagDeaths=1) AND 382 | 1 383 | ), 384 | 385 | cte_consecutive_gulag_kills as ( 386 | SELECT json_object( 387 | 'date_key', min(date_key), 388 | 'until_date_key', max(date_key), 389 | 'game_mode_sub', null, 390 | 'game_id', null, 391 | 'player_id', player_id, 392 | 'value', count(1) 393 | ) AS meta 394 | FROM cte_gulag_by_kills 395 | WHERE gulagKills=1 396 | GROUP BY player_id, gulag_group 397 | ORDER BY count(1) desc 398 | LIMIT ${num_results} 399 | ), 400 | cte_consecutive_gulag_deaths as ( 401 | SELECT json_object( 402 | 'date_key', min(date_key), 403 | 'until_date_key', max(date_key), 404 | 'game_mode_sub', null, 405 | 'game_id', null, 406 | 'player_id', player_id, 407 | 'value', count(1) 408 | ) AS meta 409 | FROM cte_gulag_by_deaths 410 | WHERE gulagDeaths=1 411 | GROUP BY player_id, gulag_group 412 | ORDER BY count(1) desc 413 | LIMIT ${num_results} 414 | ), 415 | 416 | cte_most_lastplaces as ( 417 | SELECT json_object( 418 | 'date_key', null, 419 | 'game_mode_sub', null, 420 | 'game_id', null, 421 | 'player_id', player_id, 422 | 'value', count(1) 423 | ) AS meta 424 | FROM 425 | vw_stats_wz 426 | WHERE 427 | player_id IN (SELECT * from vw_core_players) AND 428 | teamPlacement=numberOfTeams AND 429 | 1 430 | GROUP BY 431 | player_id 432 | ORDER BY 433 | count(1) DESC 434 | LIMIT ${num_results} 435 | ), 436 | 437 | cte_most_wins as ( 438 | SELECT json_object( 439 | 'date_key', null, 440 | 'game_mode_sub', null, 441 | 'game_id', null, 442 | 'player_id', player_id, 443 | 'value', count(1) 444 | ) AS meta 445 | FROM 446 | vw_stats_wz 447 | WHERE 448 | player_id IN (SELECT * from vw_core_players) AND 449 | teamPlacement=1 AND 450 | 1 451 | GROUP BY 452 | player_id 453 | ORDER BY 454 | count(1) DESC 455 | LIMIT ${num_results} 456 | ), 457 | 458 | cte_null as (SELECT 1) 459 | 460 | SELECT json_array( 461 | (select json_object('title', 'Consecutive gulag wins', 462 | 'meta', json_group_array(json(meta))) FROM cte_consecutive_gulag_kills), 463 | (select json_object('title', 'Consecutive gulag losses', 464 | 'meta', json_group_array(json(meta))) FROM cte_consecutive_gulag_deaths), 465 | (select json_object('title', 'Total wins', 466 | 'meta', json_group_array(json(meta))) FROM cte_most_wins), 467 | (select json_object('title', 'Total last places', 468 | 'meta', json_group_array(json(meta))) FROM cte_most_lastplaces) 469 | ) as leaderboard; 470 | EOF 471 | ) 472 | 473 | local outpath="${outdir_staging_data}/leaderboard_lifetime.json" 474 | echo "${data_leaderboard}" >"${outpath}" 475 | local end=$(get_ts) 476 | report_file_written "${outpath}" "${start}" "${end}" 477 | 478 | local start=$(get_ts) 479 | local data_leaderboard=$( 480 | sqlite3 "${dbfile}" <<-EOF 481 | select group_concat(leaderboards) from ( 482 | select json_object( 483 | 'mode', 484 | case category 485 | when 'wz_solo' then 'Solo' 486 | when 'wz_duos' then 'Duos' 487 | when 'wz_trios' then 'Trios' 488 | when 'wz_quads' then 'Quads' 489 | else category 490 | END, 491 | 'sortOrder', 492 | case category 493 | when 'wz_quads' then 1 494 | when 'wz_solo' then 2 495 | when 'wz_duos' then 3 496 | when 'wz_trios' then 4 497 | else 99 498 | END, 499 | 'stats', json_group_array(jsonStats) 500 | ) leaderboards from vw_team_stat_breakdowns group by category 501 | ); 502 | EOF 503 | ) 504 | 505 | local outpath="${outdir_staging_data}/team_leaderboards.json" 506 | echo "[" >"${outpath}" 507 | echo "${data_leaderboard}" >>"${outpath}" 508 | echo "]" >>"${outpath}" 509 | local end=$(get_ts) 510 | report_file_written "${outpath}" "${start}" "${end}" 511 | echo 512 | } 513 | 514 | write_recent_matches() { 515 | local -r num_results=15 516 | 517 | local start=$(get_ts) 518 | local data_recent_matches=$( 519 | sqlite3 "${dbfile}" <<-EOF 520 | WITH cte_recent_stats AS ( 521 | SELECT 522 | json_object( 523 | 'date', date_key, 524 | 'game_id', game_id, 525 | 'game_mode', ifnull(vgm.display_name, 'Unknown <' || game_mode_sub || '>'), 526 | 'player_ids', player_ids, 527 | 'player_stats', json(player_stats) 528 | ) stats 529 | FROM 530 | vw_full_game_stats 531 | LEFT JOIN 532 | vw_game_modes vgm ON vgm.id=game_mode_sub 533 | ORDER BY 534 | date_key DESC 535 | ) 536 | 537 | SELECT group_concat(stats) FROM (select * from cte_recent_stats LIMIT ${num_results}); 538 | EOF 539 | ) 540 | 541 | local outpath="${outdir_staging_data}/recent_matches.json" 542 | echo "[" >"${outpath}" 543 | echo "${data_recent_matches}" >>"${outpath}" 544 | echo "]" >>"${outpath}" 545 | local end=$(get_ts) 546 | report_file_written "${outpath}" "${start}" "${end}" 547 | 548 | echo 549 | } 550 | 551 | write_recent_sessions() { 552 | local start=$(get_ts) 553 | local data_recent_sessions=$( 554 | sqlite3 "${dbfile}" <<-EOF 555 | with cte_last_session_stats AS ( 556 | select * from ( 557 | select 558 | row_number() over (PARTITION by player_id order by session_number desc) rn, 559 | * 560 | FROM vw_player_sessions_with_stats 561 | ) vsw where rn=1 562 | ) 563 | 564 | select group_concat( 565 | json_object( 566 | 'player_id', player_id, 567 | 'stats', json(stats) 568 | ) 569 | ) from cte_last_session_stats; 570 | EOF 571 | ) 572 | 573 | local outpath="${outdir_staging_data}/recent_sessions.json" 574 | echo "[" >"${outpath}" 575 | echo "${data_recent_sessions}" >>"${outpath}" 576 | echo "]" >>"${outpath}" 577 | local end=$(get_ts) 578 | report_file_written "${outpath}" "${start}" "${end}" 579 | 580 | echo 581 | } 582 | 583 | write_player_rollup_stats_to_json() { 584 | local -r name="${1}" 585 | local -r outpath="${2}" 586 | 587 | local -r stats=$( 588 | sqlite3 "${dbfile}" <<-EOF 589 | WITH 590 | cte_stats_by_season AS ( 591 | SELECT 592 | id, 593 | sort_order, 594 | desc, 595 | player_id, 596 | sum(1) matchesPlayed, 597 | sum(damageDone) damageDone, 598 | sum(kills) kills, 599 | sum(deaths) deaths, 600 | sum(gulagKills) gulagKills, 601 | sum(gulagDeaths) gulagDeaths 602 | FROM vw_seasons vs 603 | JOIN 604 | vw_stats_wz vsw ON vsw.date_key >= vs.start AND vsw.date_key <= vs.end 605 | GROUP BY 606 | vsw.player_id, vs.id 607 | ORDER BY player_id, sort_order 608 | ), 609 | cte_stats_rollup AS ( 610 | SELECT 611 | player_id, 612 | id, 613 | desc, 614 | sort_order, 615 | matchesPlayed, 616 | json_array( 617 | json_object( 618 | 'name', 'K/D', 619 | 'value', round(kills/cast(deaths as float), 2) 620 | ), 621 | json_object( 622 | 'name', 'Avg Kills', 623 | 'value', round(kills/cast(matchesPlayed as float), 2) 624 | ), 625 | json_object( 626 | 'name', 'Dmg/Kill', 627 | 'value', cast(damageDone/kills as int) 628 | ), 629 | json_object( 630 | 'name', 'Gulag', 631 | 'value', cast(100 * gulagKills/cast(gulagKills + gulagDeaths as float) as int) || '%' 632 | ) 633 | ) stats 634 | from cte_stats_by_season 635 | ORDER BY player_id, sort_order 636 | ), 637 | 638 | cte_placements_by_season AS ( 639 | SELECT 640 | vs.id, 641 | sort_order, 642 | desc, 643 | player_id, 644 | vgm.category, 645 | round(100 * sum(teamPlacement)/cast(sum(numberOfTeams) as float), 2) avgPlacement 646 | FROM vw_seasons vs 647 | JOIN 648 | vw_stats_wz vsw ON vsw.date_key >= vs.start AND vsw.date_key <= vs.end, 649 | vw_game_modes vgm ON vgm.id=vsw.game_mode_sub 650 | GROUP BY 651 | vsw.player_id, vs.id, vgm.category 652 | ORDER BY 653 | player_id, sort_order 654 | ), 655 | cte_placements_rollup AS ( 656 | SELECT 657 | 'placements' AS 'result_type', 658 | player_id, 659 | id, 660 | desc, 661 | sort_order, 662 | json_array( 663 | json_object( 664 | 'name', 'Avg Solo', 665 | 'value', IFNULL(MAX(CASE WHEN category='wz_solo' THEN avgPlacement END), 'N/A') 666 | ), 667 | json_object( 668 | 'name', 'Avg Duos', 669 | 'value', IFNULL(MAX(CASE WHEN category='wz_duos' THEN avgPlacement END), 'N/A') 670 | ), 671 | json_object( 672 | 'name', 'Avg Trios', 673 | 'value', IFNULL(MAX(CASE WHEN category='wz_trios' THEN avgPlacement END), 'N/A') 674 | ), 675 | json_object( 676 | 'name', 'Avg Quads', 677 | 'value', IFNULL(MAX(CASE WHEN category='wz_quads' THEN avgPlacement END), 'N/A') 678 | ) 679 | ) stats 680 | FROM cte_placements_by_season 681 | GROUP BY 682 | player_id, id 683 | ORDER BY 684 | player_id, sort_order 685 | ) 686 | 687 | SELECT 688 | json_group_array( 689 | json_object( 690 | 'season_id', csr.id, 691 | 'displayName', csr.desc, 692 | 'metrics', json(csr.stats), 693 | 'placements', json(cpr.stats), 694 | 'numGames', csr.matchesPlayed 695 | ) 696 | ) stats 697 | FROM cte_stats_rollup csr 698 | JOIN cte_placements_rollup cpr USING (player_id, id) 699 | WHERE csr.player_id='${name}' 700 | GROUP BY csr.player_id 701 | EOF 702 | ) 703 | 704 | echo "${stats}" >"${outpath}" 705 | } 706 | 707 | write_player_time_stats_to_json() { 708 | local -r name="${1}" 709 | local -r start="${2}" 710 | local -r end="${3}" 711 | local -r outpath="${4}" 712 | 713 | local -r stats=$( 714 | sqlite3 "${dbfile}" <<-EOF 715 | WITH cte_stats AS ( 716 | SELECT 717 | json_object( 718 | 'date', date(date_key), 719 | 'stats', json_object( 720 | 'raw', json_object( 721 | 'matchesPlayed', matchesPlayed, 722 | 'kills', kills, 723 | 'deaths', deaths, 724 | 'gulagKills', gulagKills, 725 | 'gulagDeaths', gulagDeaths, 726 | 'headshots', headshots, 727 | 'damageDone', damageDone, 728 | 'distanceTraveled', distanceTraveled, 729 | 'kdRatio', kdRatio, 730 | 'scorePerMinute', scorePerMinute, 731 | 'monsters', monsters, 732 | 'gooseeggs', gooseeggs 733 | ), 734 | 'smoothed_3', json_object( 735 | 'matchesPlayed', sum(matchesPlayed) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 736 | 'kills', sum(kills) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 737 | 'deaths', sum(deaths) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 738 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 739 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 740 | 'headshots', sum(headshots) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 741 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 742 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 743 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 744 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 745 | 'monsters', sum(monsters) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 746 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) 747 | ), 748 | 'smoothed_7', json_object( 749 | 'matchesPlayed', sum(matchesPlayed) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 750 | 'kills', sum(kills) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 751 | 'deaths', sum(deaths) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 752 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 753 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 754 | 'headshots', sum(headshots) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 755 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 756 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 757 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 758 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 759 | 'monsters', sum(monsters) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 760 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) 761 | ), 762 | 'cumalative', json_object( 763 | 'matchesPlayed', sum(matchesPlayed) OVER(ORDER BY date_key), 764 | 'kills', sum(kills) OVER(ORDER BY date_key), 765 | 'deaths', sum(deaths) OVER(ORDER BY date_key), 766 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key), 767 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key), 768 | 'headshots', sum(headshots) OVER(ORDER BY date_key), 769 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key), 770 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key), 771 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key), 772 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key), 773 | 'monsters', sum(monsters) OVER(ORDER BY date_key), 774 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key) 775 | ) 776 | ) 777 | ) as stats 778 | FROM 779 | vw_player_stats_by_day_wz 780 | WHERE 781 | player_id='${name}' AND 782 | date_key>='${start}' AND 783 | date_key<='${end}' AND 784 | 1 785 | ORDER BY 786 | date_key 787 | ) 788 | 789 | select json_group_array(stats) 'stats' from cte_stats 790 | EOF 791 | ) 792 | 793 | echo "${stats}" >"${outpath}" 794 | } 795 | 796 | write_player_game_stats_to_json() { 797 | local -r name="${1}" 798 | local -r start="${2}" 799 | local -r end="${3}" 800 | local -r outpath="${4}" 801 | 802 | local -r stats=$( 803 | sqlite3 "${dbfile}" <<-EOF 804 | WITH cte_stats AS ( 805 | SELECT 806 | json_object( 807 | 'date', date_key, 808 | 'stats', json_object( 809 | 'raw', json_object( 810 | 'matchesPlayed', matchesPlayed, 811 | 'mode', mode, 812 | 'numberOfPlayers', numberOfPlayers, 813 | 'numberOfTeams', numberOfTeams, 814 | 'teamPlacement', teamPlacement, 815 | 'kills', kills, 816 | 'deaths', deaths, 817 | 'gulagKills', gulagKills, 818 | 'gulagDeaths', gulagDeaths, 819 | 'headshots', headshots, 820 | 'damageDone', damageDone, 821 | 'distanceTraveled', distanceTraveled, 822 | 'kdRatio', kdRatio, 823 | 'scorePerMinute', scorePerMinute, 824 | 'monsters', monsters, 825 | 'gooseeggs', gooseeggs 826 | ), 827 | 'smoothed_10', json_object( 828 | 'matchesPlayed', count(matchesPlayed) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 829 | 'kills', sum(kills) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 830 | 'deaths', sum(deaths) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 831 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 832 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 833 | 'headshots', sum(headshots) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 834 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 835 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 836 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 837 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 838 | 'monsters', sum(monsters) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW), 839 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key ROWS BETWEEN 9 PRECEDING AND CURRENT ROW) 840 | ), 841 | 'smoothed_25', json_object( 842 | 'matchesPlayed', count(matchesPlayed) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 843 | 'kills', sum(kills) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 844 | 'deaths', sum(deaths) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 845 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 846 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 847 | 'headshots', sum(headshots) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 848 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 849 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 850 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 851 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 852 | 'monsters', sum(monsters) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW), 853 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key ROWS BETWEEN 24 PRECEDING AND CURRENT ROW) 854 | ), 855 | 'cumalative', json_object( 856 | 'matchesPlayed', sum(matchesPlayed) OVER(ORDER BY date_key), 857 | 'kills', sum(kills) OVER(ORDER BY date_key), 858 | 'deaths', sum(deaths) OVER(ORDER BY date_key), 859 | 'gulagKills', sum(gulagKills) OVER(ORDER BY date_key), 860 | 'gulagDeaths', sum(gulagDeaths) OVER(ORDER BY date_key), 861 | 'headshots', sum(headshots) OVER(ORDER BY date_key), 862 | 'damageDone', sum(damageDone) OVER(ORDER BY date_key), 863 | 'distanceTraveled', sum(distanceTraveled) OVER(ORDER BY date_key), 864 | 'kdRatio', avg(kdRatio) OVER(ORDER BY date_key), 865 | 'scorePerMinute', avg(scorePerMinute) OVER(ORDER BY date_key), 866 | 'monsters', sum(monsters) OVER(ORDER BY date_key), 867 | 'gooseeggs', sum(gooseeggs) OVER(ORDER BY date_key) 868 | ) 869 | ) 870 | ) as stats 871 | FROM 872 | vw_player_stats_by_game_wz 873 | WHERE 874 | player_id='${name}' AND 875 | date_key>='${start}' AND 876 | date_key<='${end}' AND 877 | 1 878 | ORDER BY 879 | date_key 880 | ) 881 | 882 | select json_group_array(stats) 'stats' from cte_stats 883 | EOF 884 | ) 885 | 886 | echo "${stats}" >"${outpath}" 887 | } 888 | 889 | write_player_stats_to_json() { 890 | local -n player_ids=$1 891 | 892 | local -r season_starts=($( 893 | sqlite3 "${dbfile}" <<-EOF 894 | SELECT start FROM vw_seasons ORDER BY start 895 | EOF 896 | )) 897 | 898 | local -r season_ends=($( 899 | sqlite3 "${dbfile}" <<-EOF 900 | SELECT end FROM vw_seasons ORDER BY start 901 | EOF 902 | )) 903 | 904 | local -r season_ids=($( 905 | sqlite3 "${dbfile}" <<-EOF 906 | SELECT id FROM vw_seasons ORDER BY start 907 | EOF 908 | )) 909 | 910 | for ((idx = 0; idx < ${#player_ids[@]}; ++idx)); do 911 | local name="${player_ids[idx]}" 912 | 913 | local start=$(get_ts) 914 | local outpath="${outdir_staging_data}/${name}_player_stats.json" 915 | write_player_rollup_stats_to_json "${name}" "${outpath}" 916 | local end=$(get_ts) 917 | report_file_written "${outpath}" "${start}" "${end}" 918 | 919 | for ((season_idx = 0; season_idx < ${#season_starts[@]}; ++season_idx)); do 920 | local season_start="${season_starts[season_idx]}" 921 | local season_end="${season_ends[season_idx]}" 922 | local season_id="${season_ids[season_idx]}" 923 | 924 | local start=$(get_ts) 925 | local outpath="${outdir_staging_data}/${name}_${season_id}_time_wz.json" 926 | write_player_time_stats_to_json "${name}" "${season_start}" "${season_end}" "${outpath}" 927 | local end=$(get_ts) 928 | report_file_written "${outpath}" "${start}" "${end}" 929 | 930 | local start=$(get_ts) 931 | local outpath="${outdir_staging_data}/${name}_${season_id}_game_wz.json" 932 | write_player_game_stats_to_json "${name}" "${season_start}" "${season_end}" "${outpath}" 933 | local end=$(get_ts) 934 | report_file_written "${outpath}" "${start}" "${end}" 935 | done 936 | 937 | local start=$(get_ts) 938 | local data_player_sessions=$( 939 | sqlite3 "${dbfile}" <<-EOF 940 | SELECT json_object( 941 | 'playerId', '${name}', 942 | 'sessions', ( 943 | SELECT 944 | json_group_array( 945 | json_object( 946 | 'start', start, 947 | 'end', end, 948 | 'stats', json(stats) 949 | ) 950 | ) 951 | FROM vw_player_sessions_with_stats 952 | WHERE 953 | player_id='${name}' AND 954 | 1 955 | ORDER BY start desc 956 | ) 957 | ) res; 958 | EOF 959 | ) 960 | 961 | local outpath="${outdir_staging_data}/sessions_${name}.json" 962 | echo "${data_player_sessions}" >"${outpath}" 963 | local end=$(get_ts) 964 | report_file_written "${outpath}" "${start}" "${end}" 965 | 966 | 967 | local start=$(get_ts) 968 | local data_player_sessions_updated_at=$( 969 | sqlite3 "${dbfile}" <<-EOF 970 | SELECT json_object( 971 | 'updatedAt', strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 972 | ) res; 973 | EOF 974 | ) 975 | 976 | local outpath="${outdir_staging_data}/sessions_${name}_updated_at.json" 977 | echo "${data_player_sessions_updated_at}" >"${outpath}" 978 | local end=$(get_ts) 979 | report_file_written "${outpath}" "${start}" "${end}" 980 | 981 | echo 982 | done 983 | } 984 | 985 | function write_baked_assets() { 986 | cp -r index.html resources "${outdir_staging}" 987 | } 988 | 989 | function sync_files_to_dest() { 990 | rsync --verbose --checksum --progress --recursive --delete "${outdir_staging}"/ "${outdir_dest}" 991 | rm -rf "${outdir_staging}" 992 | } 993 | 994 | main() { 995 | local -r __ids=($(get_player_ids)) 996 | 997 | write_baked_assets 998 | write_player_stats_to_json __ids 999 | write_leaderboards 1000 | write_recent_matches 1001 | write_recent_sessions 1002 | write_meta 1003 | 1004 | sync_files_to_dest 1005 | } 1006 | 1007 | main 1008 | --------------------------------------------------------------------------------